mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-28 19:34:43 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 127bdb3078 | |||
| c69abee0c7 | |||
| dc3ff4b7aa | |||
| 958e08e109 | |||
| 590a11ed98 | |||
| 17544802c3 | |||
| 4a760b5743 | |||
| 888c4cb504 | |||
| 4394b324c9 | |||
| a27ffb20f0 | |||
| 81141dc562 | |||
| 285d315b4e | |||
| 656f299017 | |||
| 04bdf7732b | |||
| ab7dfb1edc | |||
| 05c3e40a4e | |||
| b6478dcebf | |||
| 571e7b852f | |||
| 888dd26e1d | |||
| 279c8bdd7f | |||
| 865e4d5c86 | |||
| 64950cd358 | |||
| 36157938a8 | |||
| c0dea334fb | |||
| 9b49daf3a9 | |||
| d6f1b08ce8 | |||
| 75cf4cc8a8 | |||
| 4a181c3574 | |||
| bfddf05782 | |||
| cdee625a13 | |||
| 5f7091ab4e | |||
| e7e7a97648 | |||
| f58b8cb67b | |||
| 775f687ca9 | |||
| 265a93927d | |||
| 3a938bb6d8 | |||
| 6f9a4e70da | |||
| dedb4133dc | |||
| 21f8184c58 | |||
| 59fe350972 | |||
| 5bdfcaac3c | |||
| 323d687173 | |||
| cdca203be2 | |||
| 77a31d2b6e | |||
| a1ed99278a | |||
| 729bd438e4 | |||
| 7304bb2751 | |||
| 456521f689 | |||
| ee69079bb4 | |||
| de14bb2e0f | |||
| 0539d56fc4 | |||
| 729adde022 | |||
| b7111d5c62 | |||
| 1669713fbc |
@@ -18,4 +18,4 @@ simonklee
|
||||
Slickstef11
|
||||
usrnk1
|
||||
vimtor
|
||||
starptech
|
||||
StarpTech
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -95,7 +95,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -143,7 +143,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -157,7 +157,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -220,7 +220,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -266,7 +266,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -286,7 +286,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -380,7 +380,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -434,7 +434,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -448,7 +448,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -460,7 +460,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -539,7 +539,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -558,7 +558,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -689,7 +689,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -765,7 +765,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -780,7 +780,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -795,7 +795,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -839,7 +839,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -852,7 +852,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -886,7 +886,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -905,7 +905,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -946,7 +946,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -973,7 +973,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1024,7 +1024,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -136,6 +136,9 @@ export async function setupTimeline(
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (settings.newLayoutDesigns === false) {
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
}
|
||||
}, input.settings ?? {})
|
||||
if (input.locale) {
|
||||
await page.addInitScript((locale) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const directoryA = "C:/server-a"
|
||||
const directoryB = "/home/server-b"
|
||||
const sessionA = session("ses_server_a", directoryA, "Server A session")
|
||||
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
|
||||
const sessionB = session("ses_server_b", directoryB, "Server B session")
|
||||
|
||||
test("session settings use the remote server context", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
await mockServers(page, permissionRequests)
|
||||
await configureServers(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||
|
||||
const dialog = page.locator(".settings-v2-dialog")
|
||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
await expect(autoAccept).toBeVisible()
|
||||
await expect(input).toBeEnabled()
|
||||
permissionRequests.length = 0
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toBeChecked()
|
||||
await expect
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
|
||||
|
||||
await dialog.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
server: serverA,
|
||||
retry: 20,
|
||||
})
|
||||
await mockServers(page, permissionRequests, permissionResponses)
|
||||
await configureServers(page, [
|
||||
{ type: "session", server: serverA, sessionId: sessionA.id },
|
||||
{ type: "session", server: serverB, sessionId: sessionB.id },
|
||||
])
|
||||
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
await expect
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await transport.waitForConnection()
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a",
|
||||
sessionID: sessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git status"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { response: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a-child",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a-child",
|
||||
sessionID: childSessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { response: "once" },
|
||||
},
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
sessionID: childSessionA.id,
|
||||
permissionID: "permission-background-a-child",
|
||||
body: { response: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type PermissionResponse = {
|
||||
origin: string
|
||||
directory?: string
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
body: unknown
|
||||
}
|
||||
|
||||
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
||||
await page.addInitScript(
|
||||
({ serverB, tabs }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
||||
},
|
||||
{ serverB, tabs },
|
||||
)
|
||||
}
|
||||
|
||||
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const remote = url.origin === serverB
|
||||
const directory = remote ? directoryB : directoryA
|
||||
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
||||
const requestDirectory = url.searchParams.get("directory")
|
||||
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||
if (route.request().method() === "POST" && response) {
|
||||
permissionResponses.push({
|
||||
origin: url.origin,
|
||||
directory: requestDirectory ?? undefined,
|
||||
sessionID: response[1]!,
|
||||
permissionID: response[2]!,
|
||||
body: route.request().postDataJSON(),
|
||||
})
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status") return json(route, {})
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||
if (current) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (url.pathname === "/permission") {
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, [])
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: remote ? sessionB.projectID : "project-server-a",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: directory,
|
||||
config: directory,
|
||||
worktree: directory,
|
||||
directory,
|
||||
home: directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: `project-${id}`,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function provider(id: string) {
|
||||
const name = id === "server-b" ? "Server B" : "Server A"
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id,
|
||||
name: `${name} Provider`,
|
||||
models: {
|
||||
[id]: {
|
||||
id,
|
||||
name: `${name} Model`,
|
||||
family: id,
|
||||
release_date: "2026-01-01",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: [id],
|
||||
default: { providerID: id, modelID: id },
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const assistants = Array.from({ length: 14 }, (_, index) =>
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: userID,
|
||||
created: 1700000001000 + index * 1_000,
|
||||
completed: index < 13,
|
||||
completed: index < initialPageSize,
|
||||
}),
|
||||
)
|
||||
const messages = [userMessage(), ...assistants]
|
||||
@@ -46,7 +48,7 @@ const scenarios = [
|
||||
test.use({ viewport: { width: 646, height: 1385 } })
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
||||
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
|
||||
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||
const pages: { before?: string; limit: number }[] = []
|
||||
const roots: { sessionID: string; messageID: string }[] = []
|
||||
@@ -101,36 +103,51 @@ for (const scenario of scenarios) {
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ userPartID, lastPartID }) => {
|
||||
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||
const sample = () => {
|
||||
if (state.armed) {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visible = (partID: string) => {
|
||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||
const rect = part?.getBoundingClientRect()
|
||||
return (
|
||||
!!rect &&
|
||||
!!view &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > view.top &&
|
||||
rect.top < view.bottom
|
||||
)
|
||||
}
|
||||
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
||||
state.samples++
|
||||
await page.addInitScript(() => {
|
||||
const visibleParts = () => {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
if (!viewport || !view) return []
|
||||
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||
.filter((part) => {
|
||||
const rect = part.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
||||
}
|
||||
const state = {
|
||||
armed: false,
|
||||
hidden: false,
|
||||
visibleParts: [] as string[],
|
||||
samples: 0,
|
||||
stop: false,
|
||||
arm() {
|
||||
state.visibleParts = visibleParts()
|
||||
state.armed = true
|
||||
},
|
||||
}
|
||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||
const sample = () => {
|
||||
if (state.armed) {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visible = (partID: string) => {
|
||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
|
||||
const rect = part?.getBoundingClientRect()
|
||||
return (
|
||||
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
)
|
||||
}
|
||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
|
||||
state.hidden = true
|
||||
state.samples++
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
},
|
||||
{ userPartID, lastPartID },
|
||||
)
|
||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
@@ -143,23 +160,28 @@ for (const scenario of scenarios) {
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`message:${userID}`,
|
||||
`messages:start:${messages.at(-2)!.info.id}`,
|
||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||
])
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
__historyRootProbe?: { armed: boolean }
|
||||
__historyRootProbe?: { arm(): void }
|
||||
}
|
||||
).__historyRootProbe!.armed = true
|
||||
).__historyRootProbe!.arm()
|
||||
})
|
||||
await waitForProbeSamples(page, 0)
|
||||
expect(await historyRootHidden(page)).toBe(false)
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: initialPageSize },
|
||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||
])
|
||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||
|
||||
const message = messageUpdated(scenario.info)
|
||||
@@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) {
|
||||
)
|
||||
}
|
||||
|
||||
function historyRootHidden(page: Page) {
|
||||
function visibleContentHidden(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
|
||||
const projectID = "proj_terminal_composer_focus"
|
||||
const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
@@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async
|
||||
await expect(composer).toBeFocused()
|
||||
await expect(composer).toHaveText("a")
|
||||
})
|
||||
|
||||
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
|
||||
const ghostty = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const created = { count: 0 }
|
||||
await page.route("**/pty", (route) => {
|
||||
created.count += 1
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
})
|
||||
})
|
||||
await page.route(/ghostty-web/, async (route) => {
|
||||
ghostty.resolve()
|
||||
await release.promise
|
||||
await route.continue()
|
||||
})
|
||||
await seedCachedTerminal(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(created.count).toBe(0)
|
||||
await ghostty.promise
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
|
||||
release.resolve()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await page.waitForTimeout(300)
|
||||
await expect(composer).toBeFocused()
|
||||
})
|
||||
|
||||
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
|
||||
const ghostty = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/ghostty-web/, async (route) => {
|
||||
ghostty.resolve()
|
||||
await release.promise
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await ghostty.promise
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
|
||||
release.resolve()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await page.waitForTimeout(50)
|
||||
await expect(composer).toBeFocused()
|
||||
})
|
||||
|
||||
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||
const created = { count: 0 }
|
||||
await page.route("**/pty", (route) => {
|
||||
created.count += 1
|
||||
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(next),
|
||||
})
|
||||
})
|
||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
|
||||
await page.getByRole("button", { name: "New terminal" }).click()
|
||||
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
return page.addInitScript(
|
||||
({ terminalKey, ptyID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||
localStorage.setItem(
|
||||
terminalKey,
|
||||
JSON.stringify({
|
||||
active: ptyID,
|
||||
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Timeline Suspense Reproduction</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #171717;
|
||||
color: #f5f5f5;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,315 @@
|
||||
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
|
||||
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
|
||||
|
||||
const rowCount = 2_000
|
||||
const rowHeight = 40
|
||||
const parameters = new URLSearchParams(location.search)
|
||||
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
|
||||
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
|
||||
|
||||
type MutationEvent = {
|
||||
kind: "removed" | "added"
|
||||
callbackTime: number
|
||||
callbackFrame: number
|
||||
routeConnectedInCallback: boolean
|
||||
nativeOffsetInCallback: number
|
||||
}
|
||||
|
||||
type Snapshot = {
|
||||
mode: {
|
||||
resource: "baseline" | "guard"
|
||||
reconnect: "baseline" | "candidate"
|
||||
}
|
||||
operation: {
|
||||
sequence: number
|
||||
phase: string
|
||||
time: number
|
||||
frame: number
|
||||
}
|
||||
resourceState: string
|
||||
routeConnected: boolean
|
||||
viewportConnected: boolean
|
||||
viewportOwnedByRoute: boolean
|
||||
sameRoute: boolean
|
||||
sameViewport: boolean
|
||||
sameSurface: boolean
|
||||
sameMountedRows: boolean
|
||||
nativeOffset: number
|
||||
coreOffset: number
|
||||
rangeStart: number
|
||||
rangeEnd: number
|
||||
indexes: number[]
|
||||
domIndexes: number[]
|
||||
logicalSurfaceHeight: number
|
||||
renderedSurfaceHeight: number
|
||||
viewportClientHeight: number
|
||||
viewportScrollHeight: number
|
||||
visibleRows: number
|
||||
minimumRowTop: number
|
||||
domScrollEvents: number
|
||||
lastScrollTrusted: boolean
|
||||
coreOffsetCallbackCalls: number
|
||||
offsetCallbackSources: "observer"[]
|
||||
rectObserverCallbacks: number
|
||||
ignoredDetachedZeroRects: number
|
||||
syntheticScrollDispatches: number
|
||||
mutationEvents: MutationEvent[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
timelineSuspense: {
|
||||
prepare: () => Promise<Snapshot>
|
||||
trigger: () => void
|
||||
resolve: () => void
|
||||
frames: (count?: number) => Promise<void>
|
||||
snapshot: () => Snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [refresh, setRefresh] = createSignal(false)
|
||||
let resolveResource: (() => void) | undefined
|
||||
const [resource] = createResource(
|
||||
refresh,
|
||||
(version) =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveResource = () => resolve(`settled-${version}`)
|
||||
}),
|
||||
{ initialValue: "settled" },
|
||||
)
|
||||
|
||||
function Route() {
|
||||
let route: HTMLElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let surface: HTMLDivElement | undefined
|
||||
let initialRoute: HTMLElement | undefined
|
||||
let initialViewport: HTMLDivElement | undefined
|
||||
let initialSurface: HTMLDivElement | undefined
|
||||
let initialRows: HTMLElement[] = []
|
||||
let phase = "mounting"
|
||||
let browserFrame = 0
|
||||
let snapshotSequence = 0
|
||||
let domScrollEvents = 0
|
||||
let lastScrollTrusted = false
|
||||
let coreOffsetCallbackCalls = 0
|
||||
let rectObserverCallbacks = 0
|
||||
let ignoredDetachedZeroRects = 0
|
||||
const offsetCallbackSources: "observer"[] = []
|
||||
const mutationEvents: MutationEvent[] = []
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: rowCount,
|
||||
getScrollElement: () => viewport ?? null,
|
||||
estimateSize: () => rowHeight,
|
||||
initialRect: { width: 900, height: 600 },
|
||||
overscan: 2,
|
||||
observeElementRect: (instance, callback) =>
|
||||
observeElementRect(instance, (rect) => {
|
||||
rectObserverCallbacks++
|
||||
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
|
||||
if (!instance.scrollElement?.isConnected && rect.height === 0) {
|
||||
ignoredDetachedZeroRects++
|
||||
return
|
||||
}
|
||||
callback(rect)
|
||||
}),
|
||||
observeElementOffset: (instance, callback) => {
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
coreOffsetCallbackCalls++
|
||||
offsetCallbackSources.push("observer")
|
||||
callback(offset, isScrolling)
|
||||
}
|
||||
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
|
||||
return observeElementOffset(instance, deliver)
|
||||
},
|
||||
})
|
||||
|
||||
const frames = async (count = 2) => {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
|
||||
const snapshot = (): Snapshot => {
|
||||
const rows = mountedRows()
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visibleRows =
|
||||
viewport?.isConnected && view
|
||||
? rows.filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
}).length
|
||||
: 0
|
||||
return {
|
||||
mode: { resource: resourceMode, reconnect: reconnectMode },
|
||||
operation: {
|
||||
sequence: ++snapshotSequence,
|
||||
phase,
|
||||
time: performance.now(),
|
||||
frame: browserFrame,
|
||||
},
|
||||
resourceState: resource.state,
|
||||
routeConnected: route?.isConnected ?? false,
|
||||
viewportConnected: viewport?.isConnected ?? false,
|
||||
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
|
||||
sameRoute: route === initialRoute,
|
||||
sameViewport: viewport === initialViewport,
|
||||
sameSurface: surface === initialSurface,
|
||||
sameMountedRows:
|
||||
initialRows.length > 0 &&
|
||||
initialRows.length === rows.length &&
|
||||
initialRows.every((row, index) => row === rows[index]),
|
||||
nativeOffset: viewport?.scrollTop ?? -1,
|
||||
coreOffset: virtualizer.scrollOffset ?? -1,
|
||||
rangeStart: virtualizer.range?.startIndex ?? -1,
|
||||
rangeEnd: virtualizer.range?.endIndex ?? -1,
|
||||
indexes: virtualizer.getVirtualItems().map((item) => item.index),
|
||||
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
|
||||
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
|
||||
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
|
||||
viewportClientHeight: viewport?.clientHeight ?? -1,
|
||||
viewportScrollHeight: viewport?.scrollHeight ?? -1,
|
||||
visibleRows,
|
||||
minimumRowTop:
|
||||
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
|
||||
domScrollEvents,
|
||||
lastScrollTrusted,
|
||||
coreOffsetCallbackCalls,
|
||||
offsetCallbackSources: [...offsetCallbackSources],
|
||||
rectObserverCallbacks,
|
||||
ignoredDetachedZeroRects,
|
||||
syntheticScrollDispatches: 0,
|
||||
mutationEvents: mutationEvents.map((event) => ({ ...event })),
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
|
||||
const routeRoot = route.parentElement
|
||||
if (!routeRoot) throw new Error("Timeline route root did not mount")
|
||||
initialRoute = route
|
||||
initialViewport = viewport
|
||||
initialSurface = surface
|
||||
viewport.addEventListener("scroll", (event) => {
|
||||
domScrollEvents++
|
||||
lastScrollTrusted = event.isTrusted
|
||||
})
|
||||
const countFrames = () => {
|
||||
browserFrame++
|
||||
requestAnimationFrame(countFrames)
|
||||
}
|
||||
requestAnimationFrame(countFrames)
|
||||
new MutationObserver((records) => {
|
||||
const callbackTime = performance.now()
|
||||
records.forEach((record) => {
|
||||
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
|
||||
if (node !== route) return
|
||||
phase = "detached"
|
||||
mutationEvents.push({
|
||||
kind: "removed",
|
||||
callbackTime,
|
||||
callbackFrame: browserFrame,
|
||||
routeConnectedInCallback: route.isConnected,
|
||||
nativeOffsetInCallback: viewport.scrollTop,
|
||||
})
|
||||
})
|
||||
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
|
||||
if (node !== route) return
|
||||
phase = "reinserted"
|
||||
mutationEvents.push({
|
||||
kind: "added",
|
||||
callbackTime,
|
||||
callbackFrame: browserFrame,
|
||||
routeConnectedInCallback: route.isConnected,
|
||||
nativeOffsetInCallback: viewport.scrollTop,
|
||||
})
|
||||
})
|
||||
})
|
||||
}).observe(routeRoot, { childList: true })
|
||||
window.timelineSuspense = {
|
||||
prepare: async () => {
|
||||
phase = "preparing"
|
||||
await frames(2)
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
await frames(3)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await frames(2)
|
||||
initialRows = mountedRows()
|
||||
phase = "prepared"
|
||||
return snapshot()
|
||||
},
|
||||
trigger: () => {
|
||||
phase = "triggering"
|
||||
setRefresh(true)
|
||||
},
|
||||
resolve: () => {
|
||||
if (!resolveResource) throw new Error("Resource is not pending")
|
||||
phase = "resolving"
|
||||
resolveResource()
|
||||
},
|
||||
frames,
|
||||
snapshot,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
|
||||
<span aria-hidden="true" style={{ display: "none" }}>
|
||||
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
|
||||
</span>
|
||||
<div
|
||||
ref={viewport}
|
||||
data-viewport
|
||||
style={{
|
||||
height: "600px",
|
||||
overflow: "auto",
|
||||
"overflow-anchor": "none",
|
||||
position: "relative",
|
||||
background: "#202020",
|
||||
outline: "1px solid #3f3f46",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={surface}
|
||||
data-surface
|
||||
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
|
||||
>
|
||||
<For each={virtualizer.getVirtualItems()}>
|
||||
{(item) => (
|
||||
<div
|
||||
data-row-index={item.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item.size}px`,
|
||||
transform: `translateY(${item.start}px)`,
|
||||
padding: "10px 14px",
|
||||
border: "0 solid #333",
|
||||
"border-bottom-width": "1px",
|
||||
}}
|
||||
>
|
||||
logical row {item.index}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Suspense>
|
||||
<Route />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => <App />, document.getElementById("root")!)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "timeline-suspense.repro.ts",
|
||||
outputDir: "../../test-results/timeline-suspense",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: "line",
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
webServer: {
|
||||
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
cwd: import.meta.dirname,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (error) => console.error(error))
|
||||
await page.goto("/")
|
||||
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
||||
})
|
||||
|
||||
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
||||
await page.goto("/?reconnect=candidate")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
||||
const before = await prepare(page)
|
||||
await triggerBaselineSuspension(page)
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.nativeOffset).toBe(0)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.indexes).toEqual(before.indexes)
|
||||
expect(pending.sameRoute).toBe(true)
|
||||
expect(pending.sameViewport).toBe(true)
|
||||
expect(pending.sameSurface).toBe(true)
|
||||
expect(pending.sameMountedRows).toBe(true)
|
||||
|
||||
await resolveSuspension(page)
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
||||
await page.waitForTimeout(250)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(after.sameRoute).toBe(true)
|
||||
expect(after.sameViewport).toBe(true)
|
||||
expect(after.sameSurface).toBe(true)
|
||||
expect(after.nativeOffset).toBe(0)
|
||||
expect(after.coreOffset).toBe(0)
|
||||
expect(after.rangeStart).toBeLessThan(10)
|
||||
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
||||
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
||||
expect(after.syntheticScrollDispatches).toBe(0)
|
||||
})
|
||||
|
||||
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
||||
page,
|
||||
}) => {
|
||||
const before = await prepare(page)
|
||||
const beforeRows = before.domIndexes
|
||||
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
||||
expect(before.logicalSurfaceHeight).toBe(80_000)
|
||||
expect(before.renderedSurfaceHeight).toBe(80_000)
|
||||
expect(before.viewportClientHeight).toBe(600)
|
||||
expect(before.viewportScrollHeight).toBe(80_000)
|
||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||
expect(before.visibleRows).toBeGreaterThan(0)
|
||||
|
||||
await triggerBaselineSuspension(page)
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.resourceState).toBe("refreshing")
|
||||
expect(pending.routeConnected).toBe(false)
|
||||
expect(pending.viewportConnected).toBe(false)
|
||||
expect(pending.viewportOwnedByRoute).toBe(true)
|
||||
expect(pending.nativeOffset).toBe(0)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.rangeStart).toBe(before.rangeStart)
|
||||
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
||||
expect(pending.indexes).toEqual(before.indexes)
|
||||
expect(pending.domIndexes).toEqual(beforeRows)
|
||||
expect(pending.sameMountedRows).toBe(true)
|
||||
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
||||
expect(pending.mutationEvents).toHaveLength(1)
|
||||
expect(pending.mutationEvents[0]).toMatchObject({
|
||||
kind: "removed",
|
||||
routeConnectedInCallback: false,
|
||||
nativeOffsetInCallback: 0,
|
||||
})
|
||||
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
||||
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
||||
|
||||
const after = await resolveSuspension(page)
|
||||
expect(after.resourceState).toBe("ready")
|
||||
expect(after.routeConnected).toBe(true)
|
||||
expect(after.viewportConnected).toBe(true)
|
||||
expect(after.viewportOwnedByRoute).toBe(true)
|
||||
expect(after.sameRoute).toBe(true)
|
||||
expect(after.sameViewport).toBe(true)
|
||||
expect(after.sameSurface).toBe(true)
|
||||
expect(after.sameMountedRows).toBe(true)
|
||||
expect(after.nativeOffset).toBe(0)
|
||||
expect(after.coreOffset).toBe(before.coreOffset)
|
||||
expect(after.rangeStart).toBe(before.rangeStart)
|
||||
expect(after.rangeEnd).toBe(before.rangeEnd)
|
||||
expect(after.indexes).toEqual(before.indexes)
|
||||
expect(after.domIndexes).toEqual(beforeRows)
|
||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||
expect(after.mutationEvents).toHaveLength(2)
|
||||
expect(after.mutationEvents[1]).toMatchObject({
|
||||
kind: "added",
|
||||
routeConnectedInCallback: true,
|
||||
nativeOffsetInCallback: 0,
|
||||
})
|
||||
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
||||
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
||||
expect(after.visibleRows).toBe(0)
|
||||
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
||||
expect(after.syntheticScrollDispatches).toBe(0)
|
||||
|
||||
await page.locator("[data-viewport]").hover()
|
||||
await page.mouse.wheel(0, 80)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const value = window.timelineSuspense.snapshot()
|
||||
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
||||
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
||||
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
||||
expect(recovered.lastScrollTrusted).toBe(true)
|
||||
expect(recovered.rangeStart).toBeLessThan(10)
|
||||
expect(recovered.visibleRows).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
||||
await page.goto("/?resource=guard")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
||||
const before = await prepare(page)
|
||||
|
||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.routeConnected).toBe(true)
|
||||
expect(pending.mutationEvents).toEqual([])
|
||||
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.visibleRows).toBeGreaterThan(0)
|
||||
|
||||
const after = await resolveSuspension(page)
|
||||
expect(after.routeConnected).toBe(true)
|
||||
expect(after.nativeOffset).toBe(before.nativeOffset)
|
||||
expect(after.coreOffset).toBe(before.coreOffset)
|
||||
expect(after.visibleRows).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
async function prepare(page: Page) {
|
||||
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
||||
expect(before.routeConnected).toBe(true)
|
||||
expect(before.viewportConnected).toBe(true)
|
||||
expect(before.viewportOwnedByRoute).toBe(true)
|
||||
expect(before.sameMountedRows).toBe(true)
|
||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||
return before
|
||||
}
|
||||
|
||||
async function triggerBaselineSuspension(page: Page) {
|
||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
}
|
||||
|
||||
async function resolveSuspension(page: Page) {
|
||||
await page.evaluate(() => window.timelineSuspense.resolve())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
return page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
plugins: [solid()],
|
||||
})
|
||||
@@ -10,6 +10,9 @@
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./reproduction/timeline-suspense/**/*.ts",
|
||||
"./reproduction/timeline-suspense/**/*.tsx",
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
|
||||
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(input, init)
|
||||
return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+17
-27
@@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() {
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
return (
|
||||
<ServerKey>
|
||||
@@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<DraftServerScopedProviders directory={directory}>
|
||||
<ModelsProvider directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
@@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</DraftServerScopedProviders>
|
||||
</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
@@ -309,24 +308,21 @@ function DesktopCommands() {
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
@@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
)
|
||||
}
|
||||
|
||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
@@ -559,13 +547,15 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
<PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
id: string
|
||||
@@ -24,6 +25,8 @@ export type CommandPaletteEntry = {
|
||||
path?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
server?: ServerConnection.Key
|
||||
project?: LocalProject
|
||||
archived?: number
|
||||
updated?: number
|
||||
}
|
||||
@@ -75,28 +78,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
|
||||
|
||||
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()()
|
||||
const serverSync = useServerSync()
|
||||
const { params, tabs } = useSessionLayout()
|
||||
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
||||
const appTabs = useTabs()
|
||||
const { tabs: sessionTabs } = useSessionLayout()
|
||||
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const filesOnly = () => props.filesOnly?.() ?? false
|
||||
|
||||
const allowedCommands = createMemo(() => {
|
||||
if (filesOnly()) return []
|
||||
return command.options.filter(
|
||||
(option) =>
|
||||
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||
)
|
||||
return commandPaletteOptions(command.options)
|
||||
})
|
||||
const commandEntries = createMemo(() => {
|
||||
const category = language.t("palette.group.commands")
|
||||
return allowedCommands().map((option) => createCommandEntry(option, category))
|
||||
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
|
||||
})
|
||||
const preferredCommandEntries = createMemo(() => {
|
||||
const all = allowedCommands()
|
||||
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||
const category = language.t("palette.group.commands")
|
||||
return sorted.map((option) => createCommandEntry(option, category))
|
||||
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
|
||||
})
|
||||
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
tabs: sessionTabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
@@ -140,36 +140,12 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||
})
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return undefined
|
||||
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||
})
|
||||
const workspaces = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
const current = project()
|
||||
if (!current) return directory ? [directory] : []
|
||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
const homedir = createMemo(() => serverSync().data.path.home)
|
||||
const sessions = createSessionEntries({
|
||||
workspaces,
|
||||
label: (directory) => {
|
||||
const current = project()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
},
|
||||
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
||||
const sessions = createServerSessionEntries({
|
||||
server: ServerConnection.key(serverSDK.server),
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
@@ -191,8 +167,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
return
|
||||
}
|
||||
if (item.type === "session") {
|
||||
if (!item.directory || !item.sessionID) return
|
||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||
if (!item.sessionID || !item.server) return
|
||||
const directory = item.project?.worktree ?? item.directory
|
||||
if (directory) {
|
||||
serverCtx.projects.open(directory)
|
||||
serverCtx.projects.touch(directory)
|
||||
}
|
||||
const tab = appTabs.addSessionTab({
|
||||
server: item.server,
|
||||
sessionId: item.sessionID,
|
||||
})
|
||||
appTabs.select(tab)
|
||||
return
|
||||
}
|
||||
if (!item.path) return
|
||||
@@ -218,7 +203,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||
return {
|
||||
id: "command:" + option.id,
|
||||
type: "command",
|
||||
@@ -230,96 +215,65 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||
export function createServerSessionEntries(props: {
|
||||
server: ServerConnection.Key
|
||||
opened: () => LocalProject[]
|
||||
stored: () => Project[]
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
const state: {
|
||||
token: number
|
||||
inflight: Promise<CommandPaletteEntry[]> | undefined
|
||||
cached: CommandPaletteEntry[] | undefined
|
||||
} = { token: 0, inflight: undefined, cached: undefined }
|
||||
let abort: AbortController | undefined
|
||||
|
||||
return (text: string) => {
|
||||
if (!text.trim()) {
|
||||
state.token += 1
|
||||
state.inflight = undefined
|
||||
state.cached = undefined
|
||||
return [] as CommandPaletteEntry[]
|
||||
onCleanup(() => abort?.abort())
|
||||
|
||||
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||
const search = text.trim()
|
||||
if (!search) {
|
||||
abort?.abort()
|
||||
return []
|
||||
}
|
||||
if (state.cached) return state.cached
|
||||
if (state.inflight) return state.inflight
|
||||
|
||||
const current = state.token
|
||||
const dirs = props.workspaces()
|
||||
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
||||
|
||||
state.inflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = props.label(directory)
|
||||
return props
|
||||
.load(directory)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title ?? props.untitled(),
|
||||
description,
|
||||
directory,
|
||||
archived: session.time?.archived,
|
||||
updated: session.time?.updated,
|
||||
})),
|
||||
)
|
||||
.catch(() => [] as SessionEntryInput[])
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (state.token !== current) return [] as CommandPaletteEntry[]
|
||||
const seen = new Set<string>()
|
||||
const next = results
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((item) => createSessionEntry(item, props.category()))
|
||||
state.cached = next
|
||||
return next
|
||||
})
|
||||
abort?.abort()
|
||||
const current = new AbortController()
|
||||
abort = current
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 100)
|
||||
current.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
if (current.signal.aborted) return []
|
||||
const opened = props.opened()
|
||||
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||
return props
|
||||
.load(search, current.signal)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||
return {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
||||
category: props.category(),
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
server: props.server,
|
||||
project,
|
||||
updated: session.time.updated,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.catch(() => [] as CommandPaletteEntry[])
|
||||
.finally(() => {
|
||||
state.inflight = undefined
|
||||
})
|
||||
|
||||
return state.inflight
|
||||
}
|
||||
}
|
||||
|
||||
type SessionEntryInput = {
|
||||
directory: string
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}
|
||||
|
||||
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
|
||||
return {
|
||||
id: `session:${input.directory}:${input.id}`,
|
||||
type: "session",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
category,
|
||||
directory: input.directory,
|
||||
sessionID: input.id,
|
||||
archived: input.archived,
|
||||
updated: input.updated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { formatKeybindParts } from "@/context/command"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import {
|
||||
createCommandPaletteCommandEntry,
|
||||
createCommandPaletteFileEntry,
|
||||
createCommandPaletteModel,
|
||||
createServerSessionEntries,
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./command-palette"
|
||||
@@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
||||
|
||||
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [active, setActive] = createSignal(0)
|
||||
|
||||
const loadItems = async (text: string) => {
|
||||
const q = text.trim()
|
||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||
@@ -41,16 +45,105 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
const category = palette.language.t("palette.group.files")
|
||||
return [
|
||||
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||
...nextSessions.filter((entry) => matchesEntry(entry, q)),
|
||||
...nextSessions,
|
||||
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||
]
|
||||
}
|
||||
|
||||
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={palette.language.t("palette.search.placeholder")}
|
||||
loadItems={loadItems}
|
||||
highlight={palette.highlight}
|
||||
select={palette.select}
|
||||
close={palette.close}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogHomeCommandPaletteV2(props: {
|
||||
server: ServerConnection.Any
|
||||
onSelectSession: (entry: CommandPaletteEntry) => void
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const serverCtx = global.ensureServerCtx(props.server)
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const commandEntries = createMemo(() => {
|
||||
const category = language.t("palette.group.commands")
|
||||
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
|
||||
})
|
||||
const sessions = createServerSessionEntries({
|
||||
server: ServerConnection.key(props.server),
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||
state.cleanup?.()
|
||||
state.cleanup = undefined
|
||||
if (item?.type !== "command") return
|
||||
state.cleanup = item.option?.onHighlight?.()
|
||||
}
|
||||
const select = (item: CommandPaletteEntry | undefined) => {
|
||||
if (!item) return
|
||||
state.committed = true
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
}
|
||||
const loadItems = async (text: string) => {
|
||||
const query = text.trim()
|
||||
if (!query) return commandEntries().slice(0, 5)
|
||||
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.committed) return
|
||||
state.cleanup?.()
|
||||
})
|
||||
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("palette.search.placeholder.home")}
|
||||
loadItems={loadItems}
|
||||
highlight={highlight}
|
||||
select={select}
|
||||
close={() => dialog.close()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteView(props: {
|
||||
placeholder: string
|
||||
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
||||
highlight: (item: CommandPaletteEntry | undefined) => void
|
||||
select: (item: CommandPaletteEntry | undefined) => void
|
||||
close: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const tabs = useTabs()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [active, setActive] = createSignal(0)
|
||||
|
||||
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||
const openSessions = createMemo(
|
||||
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
@@ -59,7 +152,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
palette.highlight(activeEntry())
|
||||
props.highlight(activeEntry())
|
||||
})
|
||||
|
||||
let resultsRef: HTMLDivElement | undefined
|
||||
@@ -86,12 +179,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
palette.select(activeEntry())
|
||||
props.select(activeEntry())
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
palette.close()
|
||||
props.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +198,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
appearance="large"
|
||||
placeholder={palette.language.t("palette.search.placeholder")}
|
||||
placeholder={props.placeholder}
|
||||
leadingIcon={<Icon name="magnifying-glass" />}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -117,7 +210,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="command-palette-v2-state">
|
||||
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
|
||||
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -132,9 +225,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
<PaletteRow
|
||||
item={item}
|
||||
active={activeEntry()?.id === item.id}
|
||||
language={palette.language}
|
||||
language={language}
|
||||
sessionOpen={
|
||||
item.server && item.sessionID
|
||||
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
||||
: false
|
||||
}
|
||||
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||
onSelect={() => palette.select(item)}
|
||||
onSelect={() => props.select(item)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -153,13 +251,19 @@ function PaletteRow(props: {
|
||||
item: CommandPaletteEntry
|
||||
active: boolean
|
||||
language: ReturnType<typeof useLanguage>
|
||||
sessionOpen: boolean
|
||||
onActive: () => void
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const session = () =>
|
||||
props.item.server && props.item.directory && props.item.sessionID
|
||||
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="command-palette-v2-row"
|
||||
class="command-palette-v2-row group"
|
||||
role="option"
|
||||
aria-selected={props.active}
|
||||
data-active={props.active ? "" : undefined}
|
||||
@@ -197,7 +301,25 @@ function PaletteRow(props: {
|
||||
</Match>
|
||||
<Match when={props.item.type === "session"}>
|
||||
<div class="command-palette-v2-row-main">
|
||||
<Icon name="status" class="command-palette-v2-row-icon" />
|
||||
<div class="relative shrink-0">
|
||||
<Show when={props.sessionOpen}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 4px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={session()}>
|
||||
{(session) => (
|
||||
<SessionTabAvatar
|
||||
project={props.item.project}
|
||||
directory={session().directory}
|
||||
sessionId={session().sessionID}
|
||||
server={session().server}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="command-palette-v2-row-text">
|
||||
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
{props.item.title}
|
||||
|
||||
@@ -1,79 +1,34 @@
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import homeImage from "@/assets/help/home.png"
|
||||
import tabsImage from "@/assets/help/tabs.png"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const helpIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-slot="icon-svg"
|
||||
>
|
||||
<path
|
||||
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
||||
fill="var(--v2-icon-icon-base)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const triggerClass =
|
||||
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
export function HelpButton() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<a
|
||||
href="https://opencode.ai"
|
||||
aria-label="Open the OpenCode website"
|
||||
data-component="icon-button-v2"
|
||||
data-size="large"
|
||||
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
platform.openLink(event.currentTarget.href)
|
||||
}}
|
||||
>
|
||||
{helpIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
||||
// setState({ dismissed: false }) // for testing
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Show when={!state.dismissed}>
|
||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||
<div
|
||||
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Tabs information"
|
||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
onClick={() => setState("dismissed", true)}
|
||||
onClick={settings.general.dismissTabsToast}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
@@ -90,7 +45,7 @@ export function TabsInfoPopup() {
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
onClick={() => {
|
||||
setState("dismissed", true)
|
||||
settings.general.dismissTabsToast()
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
@@ -118,16 +73,18 @@ export function TabsInfoPopup() {
|
||||
<DrawerContent>
|
||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
June 16
|
||||
July 14
|
||||
</p>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||
@@ -151,7 +108,7 @@ export function TabsInfoPopup() {
|
||||
<p>When you reopen the app, your tabs are still open.</p>
|
||||
<p>
|
||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||
the previous layout , you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||
will become permanent in a few weeks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2203,10 +2203,7 @@ type ComposerModelControlState = {
|
||||
|
||||
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
return (
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="sliders" size="small" />
|
||||
</div>
|
||||
<div>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
@@ -2217,17 +2214,32 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.state.options}
|
||||
current={props.state.current}
|
||||
onSelect={props.state.onSelect}
|
||||
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
triggerStyle={props.state.style}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||
<MenuV2.Trigger
|
||||
as={ButtonV2}
|
||||
data-action="prompt-agent"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="max-w-[175px] justify-start ![font-weight:440]"
|
||||
style={props.state.style}
|
||||
>
|
||||
<span class="truncate capitalize leading-5">{props.state.current}</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.RadioGroup value={props.state.current} onChange={props.state.onSelect}>
|
||||
{props.state.options.map((value) => (
|
||||
<MenuV2.RadioItem value={value} class="capitalize">
|
||||
{value}
|
||||
</MenuV2.RadioItem>
|
||||
))}
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
@@ -27,6 +27,8 @@ let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const prompt = {
|
||||
@@ -56,6 +58,7 @@ const clientFor = (directory: string) => {
|
||||
return {
|
||||
session: {
|
||||
create: async () => {
|
||||
await createSessionGate
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
data: {
|
||||
@@ -122,13 +125,14 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => ({
|
||||
usePermission: () => ({
|
||||
mock.module("@/context/permission", () => {
|
||||
const state = (server: string) => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ sessionID, directory })
|
||||
enabledAutoAccept.push({ server, sessionID, directory })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
})
|
||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: "server-key" }),
|
||||
@@ -251,6 +255,8 @@ beforeEach(() => {
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
@@ -318,7 +324,40 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("keeps auto-accept bound to the submission server", async () => {
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
permissionServer = "server-b"
|
||||
release()
|
||||
await result
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
|
||||
@@ -315,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
@@ -378,7 +379,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
Show,
|
||||
splitProps,
|
||||
type Accessor,
|
||||
type ComponentProps,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -184,9 +193,28 @@ export function PromptProjectSelector(props: {
|
||||
controller: PromptProjectController
|
||||
placement?: "bottom" | "bottom-start"
|
||||
}) {
|
||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let triggerFrame: number | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||
const ready = () => {
|
||||
if (!element.isConnected) {
|
||||
triggerFrame = requestAnimationFrame(ready)
|
||||
return
|
||||
}
|
||||
triggerFrame = undefined
|
||||
setTriggerReady(true)
|
||||
}
|
||||
ready()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
|
||||
})
|
||||
|
||||
const activeItem = () =>
|
||||
props.controller.active()
|
||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||
@@ -257,13 +285,13 @@ export function PromptProjectSelector(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
open={triggerReady() && props.controller.open()}
|
||||
placement={props.placement ?? "bottom"}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
>
|
||||
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
ref={contentRef}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[data-component="server-status-icon"] {
|
||||
color: var(--v2-icon-icon-muted);
|
||||
|
||||
[data-slot="server-status-left"],
|
||||
[data-slot="server-status-right"] {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
[data-slot="server-status-dot"] {
|
||||
fill: var(--server-status-color);
|
||||
}
|
||||
|
||||
&[data-state="reconnecting"] {
|
||||
--server-status-color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
&[data-state="disconnected"] {
|
||||
--server-status-color: var(--v2-state-fg-danger);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { ServerStatusIcon, type ServerStatusIconState } from "./server-status-icon"
|
||||
|
||||
export default {
|
||||
title: "Desktop V2/Server Status Icon",
|
||||
id: "desktop-v2-server-status-icon",
|
||||
component: ServerStatusIcon,
|
||||
tags: ["autodocs"],
|
||||
}
|
||||
|
||||
function Preview(props: { state: ServerStatusIconState; label: string }) {
|
||||
return (
|
||||
<div class="flex items-center gap-3">
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
aria-label={props.label}
|
||||
icon={<ServerStatusIcon state={props.state} />}
|
||||
/>
|
||||
<span class="text-[13px] text-v2-text-text-muted">{props.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Reconnecting = {
|
||||
render: () => <Preview state="reconnecting" label="Retrying automatically..." />,
|
||||
}
|
||||
|
||||
export const Disconnected = {
|
||||
render: () => <Preview state="disconnected" label="Server disconnected" />,
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveServerStatus } from "./server-status-icon"
|
||||
|
||||
describe("resolveServerStatus", () => {
|
||||
test("prioritizes a disconnected server over stream reconnection", () => {
|
||||
expect(resolveServerStatus(false, "reconnecting")).toBe("disconnected")
|
||||
})
|
||||
|
||||
test("shows reconnection while the server remains reachable", () => {
|
||||
expect(resolveServerStatus(true, "reconnecting")).toBe("reconnecting")
|
||||
})
|
||||
|
||||
test("stays hidden for healthy and initial connections", () => {
|
||||
expect(resolveServerStatus(true, "connected")).toBeUndefined()
|
||||
expect(resolveServerStatus(undefined, "connecting")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import "./server-status-icon.css"
|
||||
|
||||
export type ServerStatusIconState = "reconnecting" | "disconnected"
|
||||
export type ServerConnectionState = "connecting" | "connected" | "reconnecting"
|
||||
|
||||
export function resolveServerStatus(healthy: boolean | undefined, connection: ServerConnectionState) {
|
||||
if (healthy === false) return "disconnected" as const
|
||||
if (connection === "reconnecting") return "reconnecting" as const
|
||||
}
|
||||
|
||||
export function ServerStatusIcon(props: { state: ServerStatusIconState }) {
|
||||
return (
|
||||
<svg
|
||||
data-component="server-status-icon"
|
||||
data-state={props.state}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M13.3141 6.00001V14.4572H2.68555V1.54285H9.5M13.3141 14.4572V9.54285H2.68555V14.4572"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path data-slot="server-status-left" d="M8.5 11.75H8V12.25H8.5Z" />
|
||||
<path data-slot="server-status-right" d="M11 11.75H10.5V12.25H11Z" />
|
||||
<path
|
||||
data-slot="server-status-dot"
|
||||
d="M13 5C14.3807 5 15.5 3.88071 15.5 2.5C15.5 1.11929 14.3807 0 13 0C11.6193 0 10.5 1.11929 10.5 2.5C10.5 3.88071 11.6193 5 13 5Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @refresh reload
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -17,9 +16,7 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
@@ -36,8 +33,6 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||
import { useTitlebarRightMount } from "../titlebar"
|
||||
import { useSettingsDialog } from "../settings-dialog"
|
||||
import { resolveServerStatus, ServerStatusIcon, type ServerStatusIconState } from "../server-status-icon"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -149,8 +144,6 @@ export function SessionHeader() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const global = useGlobal()
|
||||
const serverSDK = useServerSDK()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
@@ -172,7 +165,6 @@ export function SessionHeader() {
|
||||
const search = settings.visibility.search
|
||||
const status = settings.visibility.status
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const openServerSettings = useSettingsDialog("servers")
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -242,25 +234,9 @@ export function SessionHeader() {
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
||||
)
|
||||
const serverStatus = createMemo<ServerStatusIconState | undefined>(() => {
|
||||
const current = serverSDK()
|
||||
return resolveServerStatus(
|
||||
global.servers.health[ServerConnection.key(current.server)]?.healthy,
|
||||
current.connection.status,
|
||||
)
|
||||
})
|
||||
const serverStatusLabel = createMemo(() => {
|
||||
if (serverStatus() === "disconnected") {
|
||||
return language.t("app.server.status.disconnected", { server: serverName(serverSDK().server) })
|
||||
}
|
||||
return language.t("app.server.status.reconnecting", { server: serverName(serverSDK().server) })
|
||||
})
|
||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||
serverStatus: isDesktop() ? serverStatus() : undefined,
|
||||
serverStatusLabel: serverStatusLabel(),
|
||||
onServerStatusClick: openServerSettings,
|
||||
toolsVisible: isDesktop() && status(),
|
||||
statusLabel: language.t("status.popover.tools.trigger"),
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
@@ -541,10 +517,7 @@ export function SessionHeader() {
|
||||
}
|
||||
|
||||
type SessionHeaderV2ActionsState = {
|
||||
serverStatus?: ServerStatusIconState
|
||||
serverStatusLabel: string
|
||||
onServerStatusClick: () => void
|
||||
toolsVisible: boolean
|
||||
statusVisible: boolean
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string[]
|
||||
@@ -557,26 +530,11 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-[6px]">
|
||||
<Show when={props.state.serverStatus}>
|
||||
{(status) => (
|
||||
<TooltipV2 placement="bottom" value={props.state.serverStatusLabel}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
onClick={props.state.onServerStatusClick}
|
||||
aria-label={props.state.serverStatusLabel}
|
||||
icon={<ServerStatusIcon state={status()} />}
|
||||
/>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.state.toolsVisible}>
|
||||
<TooltipV2 placement="bottom" value={props.state.statusLabel}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.state.statusVisible}>
|
||||
<Tooltip placement="bottom" value={props.state.statusLabel}>
|
||||
<StatusPopoverV2 />
|
||||
</TooltipV2>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipV2
|
||||
|
||||
@@ -7,7 +7,7 @@ export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
terminal.requestFocus(props.terminal.id)
|
||||
terminal.open(props.terminal.id)
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
|
||||
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
@@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
|
||||
triggerVariant: "settings" as const,
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{language.t("settings.general.row.newInterface.title")}
|
||||
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
||||
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
@@ -334,24 +379,6 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -718,6 +745,14 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SettingsList } from "./settings-list"
|
||||
@@ -20,7 +20,6 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
|
||||
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
@@ -226,6 +227,31 @@ export const SettingsGeneralV2: Component<{
|
||||
},
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<LayoutTransitionToggle
|
||||
title={language.t("settings.general.row.newInterface.title")}
|
||||
badge={language.t("settings.general.row.newInterface.badge")}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<LayoutRetirementNotice
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||
/>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
@@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
@@ -377,8 +385,8 @@ export const SettingsGeneralV2: Component<{
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showTools.title")}
|
||||
description={language.t("settings.general.row.showTools.description")}
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
@@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @ts-nocheck
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
|
||||
const copy = {
|
||||
title: "New layout",
|
||||
badge: "New",
|
||||
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
noticeTitle: "You're now using new layout",
|
||||
noticeDescription: "The previous layout is no longer available",
|
||||
dismiss: "Dismiss",
|
||||
}
|
||||
|
||||
function Frame(props) {
|
||||
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||
}
|
||||
|
||||
function ToggleExample(props) {
|
||||
const [state, setState] = createStore({ checked: props.checked })
|
||||
return (
|
||||
<Frame>
|
||||
<LayoutTransitionToggle
|
||||
title={copy.title}
|
||||
badge={copy.badge}
|
||||
description={copy.description}
|
||||
checked={state.checked}
|
||||
onChange={(checked) => setState("checked", checked)}
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function NoticeExample() {
|
||||
const [state, setState] = createStore({ dismissed: false })
|
||||
return (
|
||||
<Frame>
|
||||
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||
<LayoutRetirementNotice
|
||||
title={copy.noticeTitle}
|
||||
description={copy.noticeDescription}
|
||||
dismiss={copy.dismiss}
|
||||
onDismiss={() => setState("dismissed", true)}
|
||||
/>
|
||||
</Show>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Settings/Layout transition",
|
||||
id: "app-settings-layout-transition",
|
||||
component: LayoutTransitionToggle,
|
||||
}
|
||||
|
||||
export const NewLayoutEnabled = {
|
||||
render: () => <ToggleExample checked />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutEnabled = {
|
||||
render: () => <ToggleExample checked={false} />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutRetired = {
|
||||
render: () => <NoticeExample />,
|
||||
}
|
||||
|
||||
export const AllStates = {
|
||||
render: () => (
|
||||
<div class="flex flex-col gap-8">
|
||||
<ToggleExample checked />
|
||||
<ToggleExample checked={false} />
|
||||
<NoticeExample />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
|
||||
export function LayoutTransitionToggle(props: {
|
||||
title: string
|
||||
badge: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<div class="settings-v2-interface-feature">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{props.title}
|
||||
<Tag variant="accent">{props.badge}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={props.description}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch checked={props.checked} onChange={props.onChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutRetirementNotice(props: {
|
||||
title: string
|
||||
description: string
|
||||
dismiss: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||
{props.dismiss}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -90,6 +90,11 @@
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
.settings-v2-interface-feature [data-component="settings-v2-list"] {
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
[data-component="settings-v2-row"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -128,7 +133,7 @@
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
@@ -366,7 +371,7 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-models {
|
||||
.settings-v2-models {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { ToolsMenu } from "./tools-menu"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -281,14 +280,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const toggleMcp = useMcpToggle()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const configuredMcpNames = createMemo(() =>
|
||||
Object.keys(sync().data.config.mcp ?? {}).sort((a, b) => a.localeCompare(b)),
|
||||
)
|
||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||
const mcpError = (name: string) => {
|
||||
const status = sync().data.mcp?.[name]
|
||||
if (status?.status === "failed" || status?.status === "needs_client_registration") return status.error
|
||||
}
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
@@ -297,42 +289,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
if (settings.general.newLayoutDesigns()) {
|
||||
return (
|
||||
<ToolsMenu
|
||||
labels={{
|
||||
menu: language.t("status.popover.tools.trigger"),
|
||||
mcp: language.t("status.popover.tab.mcp"),
|
||||
lsp: language.t("status.popover.tab.lsp"),
|
||||
plugins: language.t("status.popover.tab.plugins"),
|
||||
mcpDescription: language.t("status.popover.tools.mcp.description"),
|
||||
lspDescription: language.t("status.popover.tools.lsp.description"),
|
||||
pluginsDescription: language.t("status.popover.tools.plugins.description"),
|
||||
disabled: language.t("mcp.status.disabled"),
|
||||
failed: language.t("mcp.status.failed"),
|
||||
reauthenticate: language.t("status.popover.tools.reauthenticate"),
|
||||
}}
|
||||
empty={{
|
||||
mcp: language.t("status.popover.tools.mcp.empty"),
|
||||
lsp: language.t("status.popover.tools.lsp.empty"),
|
||||
plugins: language.t("status.popover.tools.plugins.empty"),
|
||||
}}
|
||||
mcp={configuredMcpNames().map((name) => ({
|
||||
name,
|
||||
status: mcpStatus(name),
|
||||
error: mcpError(name),
|
||||
loading: !mcpStatus(name),
|
||||
pending: toggleMcp.isPending && toggleMcp.variables === name,
|
||||
onToggle: () => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
},
|
||||
}))}
|
||||
lsp={lspItems().map((item) => ({ name: item.name || item.id, status: item.status }))}
|
||||
plugins={plugins()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @refresh reload
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -9,7 +9,6 @@ import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ToolsMenuIcon } from "./tools-menu"
|
||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
@@ -88,10 +87,7 @@ function DirectoryStatusPopover() {
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.keys(sync().data.config.mcp ?? {}).flatMap((name) => {
|
||||
const status = sync().data.mcp?.[name]
|
||||
return status ? [status.status] : []
|
||||
}),
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
}),
|
||||
)
|
||||
@@ -100,7 +96,7 @@ function DirectoryStatusPopover() {
|
||||
ready: ready(),
|
||||
serverHealth: serverHealth(),
|
||||
issue: issue(),
|
||||
label: language.t("status.popover.tools.trigger"),
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
@@ -178,7 +174,14 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
trigger={<ToolsMenuIcon warning={props.state.issue} />}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
{props.state.body()}
|
||||
|
||||
@@ -23,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
autoFocus?: boolean
|
||||
onAutoFocus?: () => void
|
||||
onSubmit?: () => void
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
onConnect?: () => void
|
||||
@@ -185,7 +186,15 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const [local, others] = splitProps(props, [
|
||||
"pty",
|
||||
"class",
|
||||
"classList",
|
||||
"autoFocus",
|
||||
"onAutoFocus",
|
||||
"onConnect",
|
||||
"onConnectError",
|
||||
])
|
||||
const id = local.pty.id
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
@@ -416,6 +425,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fitAddon = fit
|
||||
serializeAddon = serializer
|
||||
|
||||
const active = document.activeElement
|
||||
t.open(container)
|
||||
useTerminalUiBindings({
|
||||
container,
|
||||
@@ -425,7 +435,22 @@ export const Terminal = (props: TerminalProps) => {
|
||||
handleLinkClick,
|
||||
})
|
||||
|
||||
if (local.autoFocus !== false) focusTerminal()
|
||||
if (local.autoFocus === true) {
|
||||
focusTerminal()
|
||||
local.onAutoFocus?.()
|
||||
}
|
||||
if (local.autoFocus !== true) {
|
||||
const restoreFocus = () => {
|
||||
const current = document.activeElement
|
||||
if (current !== container && !container.contains(current)) return
|
||||
t.blur()
|
||||
t.textarea?.blur()
|
||||
if (active instanceof HTMLElement && active.isConnected) active.focus()
|
||||
}
|
||||
restoreFocus()
|
||||
const timer = setTimeout(restoreFocus, 0)
|
||||
cleanups.push(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts) {
|
||||
void document.fonts.ready.then(scheduleFit)
|
||||
|
||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
id: "tab.new",
|
||||
category: "tab",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+t",
|
||||
keybind: "mod+t,mod+n",
|
||||
hidden: true,
|
||||
onSelect: openNewTab,
|
||||
},
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { For, type JSXElement } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ToolsMenu, ToolsMenuIcon, type ToolsMenuProps, type ToolsMenuStatus, type ToolsMenuTab } from "./tools-menu"
|
||||
|
||||
export default {
|
||||
title: "Desktop V2/Tools Menu",
|
||||
id: "desktop-v2-tools-menu",
|
||||
component: ToolsMenu,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `The desktop V2 Tools menu shown in the titlebar. Stories cover every MCP status, both LSP states, populated plugin rows, and empty states for all tabs.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const labels = {
|
||||
menu: "Tools",
|
||||
mcp: "MCP",
|
||||
lsp: "LSP",
|
||||
plugins: "Plugins",
|
||||
mcpDescription: "Add MCP servers in opencode.json",
|
||||
lspDescription: "LSPs are auto-detected as you open files",
|
||||
pluginsDescription: "Add plugins in opencode.json",
|
||||
disabled: "Disabled",
|
||||
failed: "Failed",
|
||||
reauthenticate: "Reauthenticate",
|
||||
}
|
||||
|
||||
const empty = {
|
||||
mcp: "No MCPs configured yet",
|
||||
lsp: "No active language servers yet",
|
||||
plugins: "No plugins configured yet",
|
||||
}
|
||||
|
||||
const mcp = [
|
||||
{ name: "ui-docs", status: "needs_auth" },
|
||||
{ name: "browser-tools", status: "needs_client_registration" },
|
||||
{ name: "brave-search", status: "disabled" },
|
||||
{ name: "fastmail-mcp", status: "connected" },
|
||||
{ name: "puppeteer", status: "failed" },
|
||||
{ name: "figma", status: "connected" },
|
||||
{ name: "playwright", status: "failed" },
|
||||
] satisfies Array<{ name: string; status: ToolsMenuStatus }>
|
||||
|
||||
const lsp = [
|
||||
{ name: "typescript", status: "connected" },
|
||||
{ name: "eslint", status: "connected" },
|
||||
{ name: "rust-analyzer", status: "error" },
|
||||
] satisfies ToolsMenuProps["lsp"]
|
||||
|
||||
const plugins = ["opencode-devcontainers", "opencode-daytona", "~/.config/opencode/plugins/ping.ts"]
|
||||
|
||||
const populated = {
|
||||
labels,
|
||||
empty,
|
||||
mcp,
|
||||
lsp,
|
||||
plugins,
|
||||
} satisfies Omit<ToolsMenuProps, "defaultTab">
|
||||
|
||||
function InteractiveMenu() {
|
||||
const [state, setState] = createStore({ mcp: mcp.map((item) => ({ ...item, pending: false })) })
|
||||
return (
|
||||
<MenuPreview warning>
|
||||
<ToolsMenu
|
||||
{...populated}
|
||||
mcp={state.mcp.map((item, index) => ({
|
||||
...item,
|
||||
onToggle: () => {
|
||||
if (item.status === "needs_auth" || item.status === "needs_client_registration") return
|
||||
setState("mcp", index, "status", item.status === "connected" ? "disabled" : "connected")
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</MenuPreview>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuPreview(props: { warning?: boolean; children: JSXElement }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex h-7 items-center gap-2 text-[11px] font-[530] uppercase tracking-[0.05px] text-v2-text-text-faint">
|
||||
<ToolsMenuIcon warning={props.warning} />
|
||||
{props.warning ? "warning" : "normal"}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Gallery(props: { empty?: boolean }) {
|
||||
const tabs: ToolsMenuTab[] = ["mcp", "lsp", "plugins"]
|
||||
return (
|
||||
<div class="flex flex-wrap items-start gap-6">
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<MenuPreview warning={!props.empty && tab !== "plugins"}>
|
||||
<ToolsMenu
|
||||
{...populated}
|
||||
defaultTab={tab}
|
||||
mcp={props.empty ? [] : populated.mcp}
|
||||
lsp={props.empty ? [] : populated.lsp}
|
||||
plugins={props.empty ? [] : populated.plugins}
|
||||
/>
|
||||
</MenuPreview>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Interactive = {
|
||||
render: () => <InteractiveMenu />,
|
||||
}
|
||||
|
||||
export const AllTabs = {
|
||||
name: "All populated tabs",
|
||||
render: () => <Gallery />,
|
||||
}
|
||||
|
||||
export const EmptyStates = {
|
||||
name: "All empty tabs",
|
||||
render: () => <Gallery empty />,
|
||||
}
|
||||
|
||||
export const McpPending = {
|
||||
name: "MCP pending toggle",
|
||||
render: () => (
|
||||
<MenuPreview warning>
|
||||
<ToolsMenu {...populated} mcp={populated.mcp.map((item) => ({ ...item, pending: item.name === "figma" }))} />
|
||||
</MenuPreview>
|
||||
),
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { For, Index, Show, type JSXElement } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type ToolsMenuTab = "mcp" | "lsp" | "plugins"
|
||||
export type ToolsMenuStatus = "connected" | "failed" | "error" | "needs_auth" | "needs_client_registration" | "disabled"
|
||||
|
||||
export type ToolsMenuProps = {
|
||||
defaultTab?: ToolsMenuTab
|
||||
labels: {
|
||||
menu: string
|
||||
mcp: string
|
||||
lsp: string
|
||||
plugins: string
|
||||
mcpDescription: string
|
||||
lspDescription: string
|
||||
pluginsDescription: string
|
||||
disabled: string
|
||||
failed: string
|
||||
reauthenticate: string
|
||||
}
|
||||
empty: {
|
||||
mcp: JSXElement
|
||||
lsp: JSXElement
|
||||
plugins: JSXElement
|
||||
}
|
||||
mcp: Array<{
|
||||
name: string
|
||||
status?: ToolsMenuStatus
|
||||
error?: string
|
||||
loading?: boolean
|
||||
pending?: boolean
|
||||
onToggle?: () => void
|
||||
}>
|
||||
lsp: Array<{
|
||||
name: string
|
||||
status: "connected" | "error"
|
||||
}>
|
||||
plugins: string[]
|
||||
}
|
||||
|
||||
export function ToolsMenuIcon(props: { warning?: boolean }) {
|
||||
return (
|
||||
<div class="relative size-4 text-v2-icon-icon-muted">
|
||||
<Icon name={props.warning ? "tools-warning" : "tools"} />
|
||||
<Show when={props.warning}>
|
||||
<div class="absolute right-0 top-0 size-[5px] rounded-full bg-icon-warning-base" />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolsMenu(props: ToolsMenuProps) {
|
||||
const [state, setState] = createStore({ tab: props.defaultTab ?? ("mcp" as ToolsMenuTab) })
|
||||
|
||||
return (
|
||||
<div class="w-[360px] max-w-[calc(100vw-40px)] rounded-xl bg-v2-background-bg-base p-4 [font-family:var(--v2-font-family-sans)] shadow-[var(--v2-elevation-floating)]">
|
||||
<SegmentedControlV2
|
||||
value={state.tab}
|
||||
onChange={(value) => value && setState("tab", value as ToolsMenuTab)}
|
||||
class="segmented-control-v2--full-width"
|
||||
aria-label={props.labels.menu}
|
||||
>
|
||||
<SegmentedControlItemV2 value="mcp">{props.labels.mcp}</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="lsp">{props.labels.lsp}</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="plugins">{props.labels.plugins}</SegmentedControlItemV2>
|
||||
</SegmentedControlV2>
|
||||
|
||||
<Show when={state.tab === "mcp"}>
|
||||
<Show
|
||||
when={props.mcp.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.mcp} description={props.labels.mcpDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.mcpDescription}>
|
||||
<Index each={props.mcp}>
|
||||
{(item) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status={item().status} />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{item().name}
|
||||
</span>
|
||||
<Show
|
||||
when={item().status === "needs_auth"}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={item().status === "needs_client_registration"}>
|
||||
<span
|
||||
class="max-w-40 truncate text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint"
|
||||
title={item().error}
|
||||
>
|
||||
{item().error ?? props.labels.failed}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item().status !== "needs_client_registration"}>
|
||||
<Show when={item().status === "disabled" || item().status === "failed"}>
|
||||
<span class="text-[11px] font-[440] capitalize leading-4 tracking-[0.05px] text-v2-text-text-faint">
|
||||
{item().status === "disabled" ? props.labels.disabled : props.labels.failed}
|
||||
</span>
|
||||
</Show>
|
||||
<Switch
|
||||
checked={item().status === "connected"}
|
||||
disabled={item().loading}
|
||||
hideLabel
|
||||
onChange={item().onToggle}
|
||||
readOnly={item().pending}
|
||||
>
|
||||
{item().name}
|
||||
</Switch>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ButtonV2 size="small" variant="outline" disabled={item().pending} onClick={item().onToggle}>
|
||||
{props.labels.reauthenticate}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={state.tab === "lsp"}>
|
||||
<Show
|
||||
when={props.lsp.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.lsp} description={props.labels.lspDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.lspDescription}>
|
||||
<For each={props.lsp}>
|
||||
{(item) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status={item.status} />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={state.tab === "plugins"}>
|
||||
<Show
|
||||
when={props.plugins.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.plugins} description={props.labels.pluginsDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.pluginsDescription}>
|
||||
<For each={props.plugins}>
|
||||
{(plugin) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status="connected" />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{plugin}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsList(props: { description: string; children: JSXElement }) {
|
||||
return (
|
||||
<div class="mt-2 flex flex-col">
|
||||
<div class="flex h-8 items-center px-2 text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint">
|
||||
{props.description}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsEmpty(props: { title: JSXElement; description: string }) {
|
||||
return (
|
||||
<div class="mt-4 flex flex-col gap-2 text-center text-[13px] leading-4 tracking-[-0.04px]">
|
||||
<div class="h-4 font-[530] text-v2-text-text-muted">{props.title}</div>
|
||||
<div class="h-4 font-[440] text-v2-text-text-faint">{props.description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot(props: { status?: ToolsMenuStatus }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 shrink-0 rounded-full": true,
|
||||
"bg-v2-state-fg-success": props.status === "connected",
|
||||
"bg-v2-state-fg-danger": props.status === "failed" || props.status === "error",
|
||||
"bg-v2-state-fg-warning": props.status === "needs_auth" || props.status === "needs_client_registration",
|
||||
"bg-v2-border-border-base": props.status === "disabled",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
|
||||
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
|
||||
|
||||
const paletteOptions: CommandOption[] = [
|
||||
{ id: "settings.open", title: "Open settings" },
|
||||
{ id: "session.undo", title: "Undo" },
|
||||
{ id: "file.open", title: "Open file" },
|
||||
{ id: "hidden", title: "Hidden", hidden: true },
|
||||
{ id: "disabled", title: "Disabled", disabled: true },
|
||||
]
|
||||
|
||||
describe("commandPaletteOptions", () => {
|
||||
test("keeps visible enabled commands", () => {
|
||||
expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("upsertCommandRegistration", () => {
|
||||
test("replaces keyed registrations", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||
|
||||
@@ -87,6 +87,13 @@ export interface CommandOption {
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
export function commandPaletteOptions(options: CommandOption[]) {
|
||||
return options.filter(
|
||||
(option) =>
|
||||
!option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
||||
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
|
||||
}
|
||||
@@ -375,7 +382,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
run("file.open", "palette")
|
||||
run(PALETTE_ID, "palette")
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
applyHomeSessionEvent,
|
||||
appendHomeSessionEvent,
|
||||
@@ -72,8 +73,13 @@ describe("Home V2 session index", () => {
|
||||
})
|
||||
|
||||
test("maps visible roots to Home session summaries", () => {
|
||||
const activeNull = {
|
||||
...session({ id: "active-null", updated: 20 }),
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
} as unknown as SessionV2Info
|
||||
const result = parseHomeSessionIndex([
|
||||
session({ id: "root", updated: 30 }),
|
||||
activeNull,
|
||||
session({ id: "child", parentID: "root", updated: 40 }),
|
||||
session({ id: "archived", archived: 50, updated: 50 }),
|
||||
])
|
||||
@@ -88,6 +94,10 @@ describe("Home V2 session index", () => {
|
||||
title: "root",
|
||||
time: { created: 1, updated: 30 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "active-null",
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
|
||||
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
||||
return sessions.flatMap((item) => {
|
||||
if (item.parentID || item.time.archived !== undefined) return []
|
||||
if (item.parentID || typeof item.time.archived === "number") return []
|
||||
return [toLegacySummary(item)]
|
||||
})
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb
|
||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||
const info = event.properties.info
|
||||
const index = sessions.findIndex((session) => session.id === info.id)
|
||||
if (event.type === "session.deleted" || info.parentID || info.time.archived !== undefined) {
|
||||
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||
|
||||
describe("hasCustomAgent", () => {
|
||||
test("detects explicitly custom agents", () => {
|
||||
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores built-in and unclassified agents", () => {
|
||||
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveAgent", () => {
|
||||
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
|
||||
|
||||
test("uses the requested available agent", () => {
|
||||
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
|
||||
})
|
||||
|
||||
test("defaults to build", () => {
|
||||
expect(resolveAgent(agents)?.name).toBe("build")
|
||||
expect(resolveAgent(agents, "missing")?.name).toBe("build")
|
||||
})
|
||||
|
||||
test("uses the first agent when build is unavailable", () => {
|
||||
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
|
||||
return items.some((item) => item.native === false)
|
||||
}
|
||||
|
||||
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
|
||||
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
@@ -62,9 +64,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved, , savedReady] = persisted(
|
||||
@@ -107,9 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const pickAgent = (name: string | undefined) => {
|
||||
const items = list()
|
||||
if (items.length === 0) return
|
||||
return items.find((item) => item.name === name) ?? items[0]
|
||||
return resolveAgent(list(), name)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -180,8 +182,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
visible: agentsVisible,
|
||||
current() {
|
||||
return pickAgent(scope()?.agent ?? store.current)
|
||||
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("session-level override takes precedence over directory-level", () => {
|
||||
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("parent false override takes precedence over directory-level auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("parent true override takes precedence over disabled directory fallback", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: false,
|
||||
[`${base64Encode(directory)}/root`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDirectoryAutoAccepting", () => {
|
||||
|
||||
@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
|
||||
permission: { sessionID: string },
|
||||
directory?: string,
|
||||
) {
|
||||
const value = sessionLineage(session, permission.sessionID)
|
||||
const value = sessionAutoAccept(autoAccept, session, permission, directory)
|
||||
if (value !== undefined) return value
|
||||
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
|
||||
}
|
||||
|
||||
export function sessionAutoAccept(
|
||||
autoAccept: Record<string, boolean>,
|
||||
session: { id: string; parentID?: string }[],
|
||||
permission: { sessionID: string },
|
||||
directory?: string,
|
||||
) {
|
||||
return sessionLineage(session, permission.sessionID)
|
||||
.map((id) => accepted(autoAccept, id, directory))
|
||||
.find((item): item is boolean => item !== undefined)
|
||||
return value ?? false
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import type { ServerSDK } from "@/context/server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { useSettings } from "./settings"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
sessionAutoAccept,
|
||||
} from "./permission-auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
@@ -47,234 +54,417 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const owner = getOwner()
|
||||
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
|
||||
|
||||
const activeDraft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
|
||||
return activeDraft()?.server ?? server.key
|
||||
})
|
||||
|
||||
const ensure = (key: ServerConnection.Key) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn) throw new Error(`Permission server not found: ${key}`)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const existing = states.get(ctx.sdk.scope)
|
||||
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
|
||||
return existing.state
|
||||
}
|
||||
if (existing) {
|
||||
existing.dispose()
|
||||
states.delete(ctx.sdk.scope)
|
||||
}
|
||||
const root = createRoot(
|
||||
(dispose) => ({
|
||||
key,
|
||||
dispose,
|
||||
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
)
|
||||
states.set(ctx.sdk.scope, root)
|
||||
return root.state
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const list = global.servers.list()
|
||||
const keys = new Set(list.map(ServerConnection.key))
|
||||
states.forEach((value, scope) => {
|
||||
if (keys.has(value.key)) return
|
||||
value.dispose()
|
||||
states.delete(scope)
|
||||
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
|
||||
if (replacement) ensure(ServerConnection.key(replacement))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
|
||||
let lastSelected: PermissionState | undefined
|
||||
const selected = () => {
|
||||
const key = activeServer()
|
||||
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
|
||||
lastSelected = ensure(key)
|
||||
}
|
||||
if (lastSelected) return lastSelected
|
||||
return ensure(server.key)
|
||||
}
|
||||
const activeDirectory = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
if (directory) return directory
|
||||
const draft = activeDraft()
|
||||
if (draft) return draft.directory
|
||||
if (!params.id) return
|
||||
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
|
||||
return selected().sync.session.lineage.peek(params.id)?.session.directory
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const directory = activeDirectory()
|
||||
if (!directory) return
|
||||
selected().enableConfiguredDirectory(directory)
|
||||
})
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
const directory = activeDirectory()
|
||||
if (!directory) return false
|
||||
const [store] = serverSync().child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
return selected().permissionsEnabled(directory)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] === undefined) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
serverSDK()
|
||||
.client.permission.respond(input)
|
||||
.catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
const perm = event.properties
|
||||
if (!shouldAutoRespond(perm, e.name)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return shouldAutoRespond(permission, directory)
|
||||
ready: () => selected().ready(),
|
||||
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
|
||||
currentServerState: () => selected().api,
|
||||
respond(input: Parameters<PermissionRespondFn>[0]) {
|
||||
selected().respond(input)
|
||||
},
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return selected().autoResponds(permission, directory)
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return selected().isAutoAccepting(sessionID, directory)
|
||||
},
|
||||
isAutoAcceptingDirectory(directory: string) {
|
||||
return selected().isAutoAcceptingDirectory(directory)
|
||||
},
|
||||
isAutoAccepting,
|
||||
isAutoAcceptingDirectory,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
selected().toggleAutoAccept(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
selected().toggleAutoAcceptDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
selected().enableAutoAccept(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
disable(sessionID, directory)
|
||||
selected().disableAutoAccept(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
return selected().isPermissionAllowAll(directory)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type PermissionState = ReturnType<typeof createServerPermissionState>
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||
|
||||
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
function enableConfiguredDirectory(directory: string) {
|
||||
if (meta.disposed || !ready()) return
|
||||
const [childStore] = input.sync.child(directory)
|
||||
if (childStore.config.permission !== "allow") return
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] !== undefined) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
const meta = { disposed: false }
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (request) => {
|
||||
if (meta.disposed) return
|
||||
input.sdk.client.permission.respond(request).catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function sessions(directory?: string) {
|
||||
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
||||
if (!directory) return info
|
||||
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
|
||||
}
|
||||
|
||||
function isPending(permission: PermissionRequest) {
|
||||
const pending = input.sync.session.data.permission[permission.sessionID]
|
||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||
}
|
||||
|
||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||
if (override !== undefined) return override
|
||||
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||
if (meta.disposed || !lineage) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
}
|
||||
|
||||
async function respondPending(
|
||||
permission: PermissionRequest,
|
||||
directory?: string,
|
||||
current: () => boolean = () => true,
|
||||
) {
|
||||
if (!current() || !isPending(permission)) return
|
||||
if (!(await shouldAutoRespondResolved(permission, directory))) return
|
||||
if (meta.disposed || !current() || !isPending(permission)) return
|
||||
respondOnce(permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const handlePermission = (e: PermissionEvent) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
void respondPending(event.properties, e.name)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
return
|
||||
}
|
||||
void ready.promise?.then(() => {
|
||||
if (meta.disposed) return
|
||||
handlePermission(event)
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (meta.disposed) return
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (meta.disposed) return
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(
|
||||
perm,
|
||||
directory,
|
||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const api = {
|
||||
ready: () => !meta.disposed && ready(),
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAccepting(sessionID, directory)
|
||||
},
|
||||
isAutoAcceptingDirectory(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAcceptingDirectory(directory)
|
||||
},
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
isPermissionAllowAll(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return childStore.config.permission === "allow"
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...api,
|
||||
api,
|
||||
sync: input.sync,
|
||||
enableConfiguredDirectory,
|
||||
permissionsEnabled(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return hasPermissionPromptRules(childStore.config.permission)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
@@ -143,9 +142,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const [connection, setConnection] = createStore({
|
||||
status: "connecting" as "connecting" | "connected" | "reconnecting",
|
||||
})
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -191,7 +187,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})
|
||||
},
|
||||
})
|
||||
setConnection("status", "connected")
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
@@ -223,7 +218,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
setConnection("status", "reconnecting")
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(() => {
|
||||
@@ -275,7 +269,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
connection,
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
return createSdkForServer({
|
||||
server: server.http,
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("server session", () => {
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
@@ -194,7 +194,7 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
|
||||
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 2
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
isAppUpgrade,
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveNewLayoutDesigns,
|
||||
shouldDisplayTabsToast,
|
||||
shouldEnableNewLayout,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
test("blank profiles default to the new layout", () => {
|
||||
expect(newLayoutDesignsDefault).toBe(true)
|
||||
})
|
||||
|
||||
test("hides the transition until a sunset is scheduled", () => {
|
||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||
})
|
||||
|
||||
test("existing profiles can switch before sunset", () => {
|
||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||
})
|
||||
|
||||
test("preserves explicit and default layout preferences", () => {
|
||||
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
||||
})
|
||||
|
||||
test("sunset replaces the toggle with a dismissible notice", () => {
|
||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
||||
})
|
||||
|
||||
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
||||
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||
})
|
||||
|
||||
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
||||
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
||||
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
||||
})
|
||||
|
||||
test("enables the new layout when no previous version was recorded", () => {
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
||||
})
|
||||
|
||||
test("detects upgrades only when a previous version is older", () => {
|
||||
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
||||
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
||||
})
|
||||
|
||||
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
||||
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not enable the new layout without a qualifying upgrade", () => {
|
||||
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -34,6 +35,9 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
shouldDisplayTabsToast?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -52,7 +56,70 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
export const newLayoutDesignsDefault = true
|
||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
||||
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
||||
|
||||
function compareVersions(a: string, b: string) {
|
||||
const parse = (version: string) => {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
||||
if (!match) return
|
||||
return match.slice(1).map(Number)
|
||||
}
|
||||
const left = parse(a)
|
||||
const right = parse(b)
|
||||
if (!left || !right) return
|
||||
const index = left.findIndex((part, index) => part !== right[index])
|
||||
return index === -1 ? 0 : left[index]! - right[index]!
|
||||
}
|
||||
|
||||
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
||||
if (!previous || !current) return false
|
||||
const comparison = compareVersions(current, previous)
|
||||
return comparison !== undefined && comparison > 0
|
||||
}
|
||||
|
||||
export function shouldDisplayTabsToast(
|
||||
previous: string | undefined,
|
||||
current: string | undefined,
|
||||
existingInstall: boolean,
|
||||
) {
|
||||
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
||||
}
|
||||
|
||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||
if (!current) return false
|
||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
||||
if (!isAppUpgrade(previous, current)) return false
|
||||
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
||||
return (
|
||||
previousComparison !== undefined &&
|
||||
currentComparison !== undefined &&
|
||||
previousComparison <= 0 &&
|
||||
currentComparison > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||
return {
|
||||
available: scheduled && eligible && !retired,
|
||||
notice: scheduled && eligible && retired && !dismissed,
|
||||
}
|
||||
}
|
||||
|
||||
export const maximumSunsetTimeout = 2_147_483_647
|
||||
|
||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
||||
}
|
||||
|
||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
||||
if (retired) return true
|
||||
return preference ?? fallback
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -152,7 +219,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const [launch, setLaunch, , launchReady] = persisted(
|
||||
"app-version.v1",
|
||||
createStore<{ version?: string }>({ version: undefined }),
|
||||
)
|
||||
const [launchState, setLaunchState] = createStore({
|
||||
classified: false,
|
||||
migrationApplied: false,
|
||||
previous: undefined as string | undefined,
|
||||
})
|
||||
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)
|
||||
@@ -160,9 +237,87 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
)
|
||||
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
|
||||
const sunset = oldInterfaceSunset
|
||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||
const layoutUpgrade = createMemo(() =>
|
||||
launchState.classified && !launchState.migrationApplied
|
||||
? shouldEnableNewLayout(launchState.previous, platform.version)
|
||||
: false,
|
||||
)
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (layoutUpgrade()) return true
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
if (!layoutTransitionClassified()) {
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
legacyNewLayoutDesignsDefault,
|
||||
)
|
||||
}
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||
)
|
||||
})
|
||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||
|
||||
if (sunset && !oldInterfaceRetired()) {
|
||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const checkSunset = () => {
|
||||
if (Date.now() >= sunset.getTime()) {
|
||||
setOldInterfaceRetired(true)
|
||||
return
|
||||
}
|
||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
||||
}
|
||||
checkSunset()
|
||||
onCleanup(() => {
|
||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!launchReady() || launchState.classified) return
|
||||
setLaunchState({
|
||||
classified: true,
|
||||
previous: launch.version,
|
||||
})
|
||||
if (!platform.version || launch.version === platform.version) return
|
||||
setLaunch("version", platform.version)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
||||
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
}
|
||||
setLaunchState("migrationApplied", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified) return
|
||||
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
||||
if (!launchState.previous && !layoutTransitionClassified()) return
|
||||
setStore(
|
||||
"general",
|
||||
"shouldDisplayTabsToast",
|
||||
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !oldInterfaceRetired()) return
|
||||
if (store.general?.newLayoutDesigns === true) return
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
@@ -250,7 +405,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
const next = oldInterfaceRetired() ? true : value
|
||||
if (newLayoutDesigns() === next) return
|
||||
setStore("general", "newLayoutDesigns", next)
|
||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||
},
|
||||
layoutTransitionClassified,
|
||||
setOldLayoutEligible(eligible: boolean) {
|
||||
const current = store.general?.layoutTransitionEligible
|
||||
if (typeof current === "boolean") return
|
||||
setStore("general", "layoutTransitionEligible", eligible)
|
||||
},
|
||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||
dismissNewInterfaceNotice() {
|
||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||
},
|
||||
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
||||
dismissTabsToast() {
|
||||
setStore("general", "shouldDisplayTabsToast", false)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
|
||||
@@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||
return tab
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
const draftID = uuid()
|
||||
const tab = { type: "draft" as const, draftID, ...draft }
|
||||
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||
void startTransition(() => {
|
||||
await startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push(tab)
|
||||
@@ -220,6 +220,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
navigate(draftHref(draftID))
|
||||
})
|
||||
return tab
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
void startTransition(() => {
|
||||
|
||||
@@ -163,6 +163,43 @@ function createWorkspaceTerminalSession(
|
||||
all: [],
|
||||
}),
|
||||
)
|
||||
const [ui, setUi] = createStore({
|
||||
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
|
||||
})
|
||||
const focus = { request: 0 }
|
||||
|
||||
const requestFocus = (id?: string, pending = false) => {
|
||||
focus.request += 1
|
||||
setUi("focus", { request: focus.request, id, pending })
|
||||
return focus.request
|
||||
}
|
||||
|
||||
const focusRequested = (id?: string) => {
|
||||
if (!id) return false
|
||||
if (!ui.focus || ui.focus.pending) return false
|
||||
return !ui.focus.id || ui.focus.id === id
|
||||
}
|
||||
|
||||
const consumeFocus = (id: string) => {
|
||||
if (!focusRequested(id)) return
|
||||
setUi("focus", undefined)
|
||||
}
|
||||
|
||||
const cancelFocus = (request?: number) => {
|
||||
if (request !== undefined && ui.focus?.request !== request) return
|
||||
setUi("focus", undefined)
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
const cancelOnOutsideFocus = (event: FocusEvent) => {
|
||||
if (!ui.focus) return
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.target.closest("#terminal-panel")) return
|
||||
cancelFocus()
|
||||
}
|
||||
document.addEventListener("focusin", cancelOnOutsideFocus)
|
||||
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
|
||||
}
|
||||
|
||||
const pickNextTerminalNumber = () => {
|
||||
const existingTitleNumbers = new Set(
|
||||
@@ -267,23 +304,33 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", [])
|
||||
})
|
||||
},
|
||||
new() {
|
||||
new(options?: { focus?: boolean }) {
|
||||
const nextNumber = pickNextTerminalNumber()
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: defaultTitle(nextNumber) })
|
||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
if (!id) {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
return
|
||||
}
|
||||
const newTerminal = {
|
||||
id,
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
titleNumber: nextNumber,
|
||||
}
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
batch(() => {
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
|
||||
setUi("focus", { request: focusRequest, id, pending: false })
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
console.error("Failed to create terminal", error)
|
||||
})
|
||||
},
|
||||
@@ -324,6 +371,18 @@ function createWorkspaceTerminalSession(
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
},
|
||||
requestFocus(id?: string) {
|
||||
requestFocus(id)
|
||||
},
|
||||
focusRequested(id?: string) {
|
||||
return focusRequested(id)
|
||||
},
|
||||
consumeFocus(id: string) {
|
||||
consumeFocus(id)
|
||||
},
|
||||
cancelFocus() {
|
||||
cancelFocus()
|
||||
},
|
||||
next() {
|
||||
const index = store.all.findIndex((x) => x.id === store.active)
|
||||
if (index === -1) return
|
||||
@@ -442,13 +501,17 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: () => workspace().new(),
|
||||
new: (options?: { focus?: boolean }) => workspace().new(options),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
bind: () => workspace(),
|
||||
open: (id: string) => workspace().open(id),
|
||||
requestFocus: (id?: string) => workspace().requestFocus(id),
|
||||
focusRequested: (id?: string) => workspace().focusRequested(id),
|
||||
consumeFocus: (id: string) => workspace().consumeFocus(id),
|
||||
cancelFocus: () => workspace().cancelFocus(),
|
||||
close: (id: string) => workspace().close(id),
|
||||
move: (id: string, to: number) => workspace().move(id, to),
|
||||
next: () => workspace().next(),
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "إلغاء مشاركة الجلسة",
|
||||
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
|
||||
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
|
||||
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
|
||||
"palette.empty": "لا توجد نتائج",
|
||||
"palette.group.commands": "الأوامر",
|
||||
"palette.group.files": "الملفات",
|
||||
@@ -621,14 +622,6 @@ export const dict = {
|
||||
"session.header.open.menu": "خيارات الفتح",
|
||||
"session.header.open.copyPath": "نسخ المسار",
|
||||
"status.popover.trigger": "الحالة",
|
||||
"status.popover.tools.trigger": "الأدوات",
|
||||
"status.popover.tools.mcp.description": "أضف خوادم MCP في opencode.json",
|
||||
"status.popover.tools.lsp.description": "يتم اكتشاف خوادم LSP تلقائيًا عند فتح الملفات",
|
||||
"status.popover.tools.plugins.description": "أضف الإضافات في opencode.json",
|
||||
"status.popover.tools.mcp.empty": "لم يتم إعداد أي خوادم MCP بعد",
|
||||
"status.popover.tools.lsp.empty": "لا توجد خوادم لغة نشطة بعد",
|
||||
"status.popover.tools.plugins.empty": "لم يتم إعداد أي إضافات بعد",
|
||||
"status.popover.tools.reauthenticate": "إعادة المصادقة",
|
||||
"status.popover.ariaLabel": "إعدادات الخوادم",
|
||||
"status.popover.tab.servers": "الخوادم",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -730,8 +723,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب",
|
||||
"settings.general.row.showStatus.title": "حالة الخادم",
|
||||
"settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان",
|
||||
"settings.general.row.showTools.title": "قائمة الأدوات",
|
||||
"settings.general.row.showTools.description": "إظهار وصول سريع إلى MCP وLSP والإضافات في شريط العنوان",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
|
||||
@@ -745,9 +736,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها",
|
||||
"settings.general.row.newInterface.title": "التخطيط الجديد",
|
||||
"settings.general.row.newInterface.badge": "جديد",
|
||||
"settings.general.row.newInterface.description":
|
||||
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
|
||||
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
|
||||
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
|
||||
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
|
||||
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
@@ -939,8 +934,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
|
||||
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
|
||||
"app.server.status.reconnecting": "جارٍ إعادة الاتصال بـ {{server}}...",
|
||||
"app.server.status.disconnected": "تعذر الاتصال بـ {{server}}",
|
||||
"app.server.otherServers": "خوادم أخرى",
|
||||
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
|
||||
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "Parar de compartilhar sessão",
|
||||
"command.session.unshare.description": "Parar de compartilhar esta sessão",
|
||||
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
|
||||
"palette.search.placeholder.home": "Buscar comandos e sessões",
|
||||
"palette.empty": "Nenhum resultado encontrado",
|
||||
"palette.group.commands": "Comandos",
|
||||
"palette.group.files": "Arquivos",
|
||||
@@ -627,14 +628,6 @@ export const dict = {
|
||||
"session.header.open.menu": "Opções de abertura",
|
||||
"session.header.open.copyPath": "Copiar caminho",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Ferramentas",
|
||||
"status.popover.tools.mcp.description": "Adicione servidores MCP no opencode.json",
|
||||
"status.popover.tools.lsp.description": "Os LSPs são detectados automaticamente ao abrir arquivos",
|
||||
"status.popover.tools.plugins.description": "Adicione plugins no opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Nenhum MCP configurado ainda",
|
||||
"status.popover.tools.lsp.empty": "Nenhum servidor de linguagem ativo ainda",
|
||||
"status.popover.tools.plugins.empty": "Nenhum plugin configurado ainda",
|
||||
"status.popover.tools.reauthenticate": "Autenticar novamente",
|
||||
"status.popover.ariaLabel": "Configurações de servidores",
|
||||
"status.popover.tab.servers": "Servidores",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -741,8 +734,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Mostrar o botão do terminal na barra de título do desktop",
|
||||
"settings.general.row.showStatus.title": "Status do servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar o botão de status do servidor na barra de título",
|
||||
"settings.general.row.showTools.title": "Menu de ferramentas",
|
||||
"settings.general.row.showTools.description": "Mostrar acesso rápido a MCPs, LSPs e plugins na barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
|
||||
@@ -756,9 +747,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.newLayoutDesigns.title": "Novo layout e design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados",
|
||||
"settings.general.row.newInterface.title": "Novo layout",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
|
||||
@@ -953,8 +948,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Não foi possível conectar a {{server}}",
|
||||
"app.server.retrying": "Tentando novamente automaticamente...",
|
||||
"app.server.status.reconnecting": "Reconectando a {{server}}...",
|
||||
"app.server.status.disconnected": "Não foi possível conectar a {{server}}",
|
||||
"app.server.otherServers": "Outros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "nome de usuário",
|
||||
"dialog.server.add.passwordPlaceholder": "senha",
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
|
||||
|
||||
"palette.search.placeholder": "Pretraži datoteke, komande i sesije",
|
||||
"palette.search.placeholder.home": "Pretraži komande i sesije",
|
||||
"palette.empty": "Nema rezultata",
|
||||
"palette.group.commands": "Komande",
|
||||
"palette.group.files": "Datoteke",
|
||||
@@ -685,14 +686,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopiraj putanju",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Alati",
|
||||
"status.popover.tools.mcp.description": "Dodajte MCP servere u opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP serveri se automatski otkrivaju dok otvarate datoteke",
|
||||
"status.popover.tools.plugins.description": "Dodajte dodatke u opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Još nema konfigurisanih MCP servera",
|
||||
"status.popover.tools.lsp.empty": "Još nema aktivnih jezičkih servera",
|
||||
"status.popover.tools.plugins.empty": "Još nema konfigurisanih dodataka",
|
||||
"status.popover.tools.reauthenticate": "Ponovo se autentificiraj",
|
||||
"status.popover.ariaLabel": "Konfiguracije servera",
|
||||
"status.popover.tab.servers": "Serveri",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -805,8 +798,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Prikaži dugme terminala u naslovnoj traci desktop aplikacije",
|
||||
"settings.general.row.showStatus.title": "Status servera",
|
||||
"settings.general.row.showStatus.description": "Prikaži dugme statusa servera u naslovnoj traci",
|
||||
"settings.general.row.showTools.title": "Meni alata",
|
||||
"settings.general.row.showTools.description": "Prikaži brzi pristup MCP, LSP i dodacima u naslovnoj traci",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Donja navigacija",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Postavi naslovnu traku i kartice sesije na dno ekrana na mobilnim uređajima",
|
||||
@@ -821,9 +812,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije",
|
||||
"settings.general.row.newInterface.title": "Novi raspored",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
|
||||
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
|
||||
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
|
||||
@@ -1029,8 +1024,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
|
||||
"app.server.retrying": "Automatski ponovni pokušaj...",
|
||||
"app.server.status.reconnecting": "Ponovno povezivanje sa {{server}}...",
|
||||
"app.server.status.disconnected": "Nije moguće povezati se sa {{server}}",
|
||||
"app.server.otherServers": "Drugi serveri",
|
||||
"dialog.server.add.usernamePlaceholder": "korisničko ime",
|
||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Stop med at dele denne session",
|
||||
|
||||
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
|
||||
"palette.search.placeholder.home": "Søg i kommandoer og sessioner",
|
||||
"palette.empty": "Ingen resultater fundet",
|
||||
"palette.group.commands": "Kommandoer",
|
||||
"palette.group.files": "Filer",
|
||||
@@ -680,14 +681,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopier sti",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Værktøjer",
|
||||
"status.popover.tools.mcp.description": "Tilføj MCP-servere i opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP'er registreres automatisk, når du åbner filer",
|
||||
"status.popover.tools.plugins.description": "Tilføj plugins i opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Ingen MCP'er konfigureret endnu",
|
||||
"status.popover.tools.lsp.empty": "Ingen aktive sprogservere endnu",
|
||||
"status.popover.tools.plugins.empty": "Ingen plugins konfigureret endnu",
|
||||
"status.popover.tools.reauthenticate": "Godkend igen",
|
||||
"status.popover.ariaLabel": "Serverkonfigurationer",
|
||||
"status.popover.tab.servers": "Servere",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -799,8 +792,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Vis terminalknappen i desktopappens titellinje",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Vis serverstatusknappen i titellinjen",
|
||||
"settings.general.row.showTools.title": "Værktøjsmenu",
|
||||
"settings.general.row.showTools.description": "Vis hurtig adgang til MCP'er, LSP'er og plugins i titellinjen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
|
||||
@@ -814,9 +805,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nyt layout og design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen",
|
||||
"settings.general.row.newInterface.title": "Nyt layout",
|
||||
"settings.general.row.newInterface.badge": "Ny",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
|
||||
"settings.general.row.pinchZoom.title": "Knib for at zoome",
|
||||
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
|
||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
||||
@@ -1021,8 +1016,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Kunne ikke nå {{server}}",
|
||||
"app.server.retrying": "Prøver igen automatisk...",
|
||||
"app.server.status.reconnecting": "Opretter forbindelse til {{server}} igen...",
|
||||
"app.server.status.disconnected": "Kunne ikke oprette forbindelse til {{server}}",
|
||||
"app.server.otherServers": "Andre servere",
|
||||
"dialog.server.add.usernamePlaceholder": "brugernavn",
|
||||
"dialog.server.add.passwordPlaceholder": "adgangskode",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const dict = {
|
||||
"command.session.unshare": "Teilen der Sitzung aufheben",
|
||||
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
|
||||
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
|
||||
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
|
||||
"palette.empty": "Keine Ergebnisse gefunden",
|
||||
"palette.group.commands": "Befehle",
|
||||
"palette.group.files": "Dateien",
|
||||
@@ -636,14 +637,6 @@ export const dict = {
|
||||
"session.header.open.menu": "Öffnen-Optionen",
|
||||
"session.header.open.copyPath": "Pfad kopieren",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Werkzeuge",
|
||||
"status.popover.tools.mcp.description": "MCP-Server in opencode.json hinzufügen",
|
||||
"status.popover.tools.lsp.description": "LSPs werden beim Öffnen von Dateien automatisch erkannt",
|
||||
"status.popover.tools.plugins.description": "Plugins in opencode.json hinzufügen",
|
||||
"status.popover.tools.mcp.empty": "Noch keine MCPs konfiguriert",
|
||||
"status.popover.tools.lsp.empty": "Noch keine aktiven Sprachserver",
|
||||
"status.popover.tools.plugins.empty": "Noch keine Plugins konfiguriert",
|
||||
"status.popover.tools.reauthenticate": "Erneut authentifizieren",
|
||||
"status.popover.ariaLabel": "Serverkonfigurationen",
|
||||
"status.popover.tab.servers": "Server",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -753,8 +746,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Terminal-Schaltfläche in der Desktop-Titelleiste anzeigen",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Serverstatus-Schaltfläche in der Titelleiste anzeigen",
|
||||
"settings.general.row.showTools.title": "Werkzeugmenü",
|
||||
"settings.general.row.showTools.description": "Schnellzugriff auf MCPs, LSPs und Plugins in der Titelleiste anzeigen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation unten",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Titelleiste und Sitzungs-Tabs auf Mobilgeräten am unteren Bildschirmrand platzieren",
|
||||
@@ -769,9 +760,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.newLayoutDesigns.title": "Neues Layout und Design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren",
|
||||
"settings.general.row.newInterface.title": "Neues Layout",
|
||||
"settings.general.row.newInterface.badge": "Neu",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Verwerfen",
|
||||
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
|
||||
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
|
||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||
@@ -965,8 +960,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
|
||||
"app.server.retrying": "Automatische erneute Verbindung...",
|
||||
"app.server.status.reconnecting": "Verbindung zu {{server}} wird wiederhergestellt...",
|
||||
"app.server.status.disconnected": "Verbindung zu {{server}} konnte nicht hergestellt werden",
|
||||
"app.server.otherServers": "Andere Server",
|
||||
"dialog.server.add.usernamePlaceholder": "Benutzername",
|
||||
"dialog.server.add.passwordPlaceholder": "Passwort",
|
||||
|
||||
+13
-19
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Stop sharing this session",
|
||||
|
||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||
"palette.search.placeholder.home": "Search commands and sessions",
|
||||
"palette.empty": "No results found",
|
||||
"palette.group.commands": "Commands",
|
||||
"palette.group.files": "Files",
|
||||
@@ -332,8 +333,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Could not reach {{server}}",
|
||||
"app.server.retrying": "Retrying automatically...",
|
||||
"app.server.status.reconnecting": "Reconnecting to {{server}}...",
|
||||
"app.server.status.disconnected": "Couldn't connect to {{server}}",
|
||||
"app.server.otherServers": "Other servers",
|
||||
|
||||
"dialog.server.title": "Servers",
|
||||
@@ -730,14 +729,6 @@ export const dict = {
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.ariaLabel": "Server configurations",
|
||||
"status.popover.tools.trigger": "Tools",
|
||||
"status.popover.tools.mcp.description": "Add MCP servers in opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSPs are auto-detected as you open files",
|
||||
"status.popover.tools.plugins.description": "Add plugins in opencode.json",
|
||||
"status.popover.tools.mcp.empty": "No MCPs configured yet",
|
||||
"status.popover.tools.lsp.empty": "No active language servers yet",
|
||||
"status.popover.tools.plugins.empty": "No plugins configured yet",
|
||||
"status.popover.tools.reauthenticate": "Reauthenticate",
|
||||
"status.popover.tab.servers": "Servers",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
@@ -865,9 +856,8 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
"settings.general.row.shell.title": "Terminal Shell",
|
||||
"settings.general.row.shell.description":
|
||||
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
|
||||
"settings.general.row.shell.title": "Terminal shell",
|
||||
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
|
||||
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
||||
"settings.general.row.shell.terminalOnly": "terminal only",
|
||||
"settings.general.row.appearance.title": "Appearance",
|
||||
@@ -896,13 +886,12 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showTools.title": "Tools menu",
|
||||
"settings.general.row.showTools.description": "Show quick access to MCPs, LSPs, and plugins in the title bar",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -911,8 +900,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.newInterface.title": "New layout",
|
||||
"settings.general.row.newInterface.badge": "New",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
"settings.general.row.newInterfaceNotice.title": "You're now using new layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Dismiss",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Dejar de compartir esta sesión",
|
||||
|
||||
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
|
||||
"palette.search.placeholder.home": "Buscar comandos y sesiones",
|
||||
"palette.empty": "No se encontraron resultados",
|
||||
"palette.group.commands": "Comandos",
|
||||
"palette.group.files": "Archivos",
|
||||
@@ -686,14 +687,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Copiar ruta",
|
||||
|
||||
"status.popover.trigger": "Estado",
|
||||
"status.popover.tools.trigger": "Herramientas",
|
||||
"status.popover.tools.mcp.description": "Añade servidores MCP en opencode.json",
|
||||
"status.popover.tools.lsp.description": "Los LSP se detectan automáticamente al abrir archivos",
|
||||
"status.popover.tools.plugins.description": "Añade plugins en opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Aún no hay MCP configurados",
|
||||
"status.popover.tools.lsp.empty": "Aún no hay servidores de lenguaje activos",
|
||||
"status.popover.tools.plugins.empty": "Aún no hay plugins configurados",
|
||||
"status.popover.tools.reauthenticate": "Volver a autenticar",
|
||||
"status.popover.ariaLabel": "Configuraciones del servidor",
|
||||
"status.popover.tab.servers": "Servidores",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -810,8 +803,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Mostrar el botón del terminal en la barra de título",
|
||||
"settings.general.row.showStatus.title": "Estado del servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar el botón de estado del servidor en la barra de título",
|
||||
"settings.general.row.showTools.title": "Menú de herramientas",
|
||||
"settings.general.row.showTools.description": "Mostrar acceso rápido a MCP, LSP y plugins en la barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegación inferior",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles",
|
||||
@@ -826,9 +817,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones",
|
||||
"settings.general.row.newInterface.title": "Nuevo diseño",
|
||||
"settings.general.row.newInterface.badge": "Nuevo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño",
|
||||
"settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
|
||||
@@ -1037,8 +1032,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "No se pudo conectar con {{server}}",
|
||||
"app.server.retrying": "Reintentando automáticamente...",
|
||||
"app.server.status.reconnecting": "Reconectando con {{server}}...",
|
||||
"app.server.status.disconnected": "No se pudo conectar con {{server}}",
|
||||
"app.server.otherServers": "Otros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "usuario",
|
||||
"dialog.server.add.passwordPlaceholder": "contraseña",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "Ne plus partager la session",
|
||||
"command.session.unshare.description": "Arrêter de partager cette session",
|
||||
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
|
||||
"palette.search.placeholder.home": "Rechercher des commandes et des sessions",
|
||||
"palette.empty": "Aucun résultat trouvé",
|
||||
"palette.group.commands": "Commandes",
|
||||
"palette.group.files": "Fichiers",
|
||||
@@ -632,14 +633,6 @@ export const dict = {
|
||||
"session.header.open.menu": "Options d'ouverture",
|
||||
"session.header.open.copyPath": "Copier le chemin",
|
||||
"status.popover.trigger": "Statut",
|
||||
"status.popover.tools.trigger": "Outils",
|
||||
"status.popover.tools.mcp.description": "Ajouter des serveurs MCP dans opencode.json",
|
||||
"status.popover.tools.lsp.description": "Les LSP sont détectés automatiquement à l’ouverture des fichiers",
|
||||
"status.popover.tools.plugins.description": "Ajouter des plugins dans opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Aucun MCP configuré pour le moment",
|
||||
"status.popover.tools.lsp.empty": "Aucun serveur de langage actif pour le moment",
|
||||
"status.popover.tools.plugins.empty": "Aucun plugin configuré pour le moment",
|
||||
"status.popover.tools.reauthenticate": "S’authentifier à nouveau",
|
||||
"status.popover.ariaLabel": "Configurations des serveurs",
|
||||
"status.popover.tab.servers": "Serveurs",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -750,9 +743,6 @@ export const dict = {
|
||||
"Afficher le bouton du terminal dans la barre de titre de l'application desktop",
|
||||
"settings.general.row.showStatus.title": "État du serveur",
|
||||
"settings.general.row.showStatus.description": "Afficher le bouton d'état du serveur dans la barre de titre",
|
||||
"settings.general.row.showTools.title": "Menu Outils",
|
||||
"settings.general.row.showTools.description":
|
||||
"Afficher un accès rapide aux MCP, LSP et plugins dans la barre de titre",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation en bas",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer la barre de titre et les onglets de session en bas de l'écran sur mobile",
|
||||
@@ -767,9 +757,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session",
|
||||
"settings.general.row.newInterface.title": "Nouvelle mise en page",
|
||||
"settings.general.row.newInterface.badge": "Nouveau",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page",
|
||||
"settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Ignorer",
|
||||
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
|
||||
@@ -965,8 +959,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Impossible de joindre {{server}}",
|
||||
"app.server.retrying": "Nouvelle tentative automatique...",
|
||||
"app.server.status.reconnecting": "Reconnexion à {{server}}...",
|
||||
"app.server.status.disconnected": "Impossible de se connecter à {{server}}",
|
||||
"app.server.otherServers": "Autres serveurs",
|
||||
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
|
||||
"dialog.server.add.passwordPlaceholder": "mot de passe",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "セッションの共有を停止",
|
||||
"command.session.unshare.description": "このセッションの共有を停止",
|
||||
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
|
||||
"palette.search.placeholder.home": "コマンドとセッションを検索",
|
||||
"palette.empty": "結果が見つかりません",
|
||||
"palette.group.commands": "コマンド",
|
||||
"palette.group.files": "ファイル",
|
||||
@@ -623,14 +624,6 @@ export const dict = {
|
||||
"session.header.open.menu": "開くオプション",
|
||||
"session.header.open.copyPath": "パスをコピー",
|
||||
"status.popover.trigger": "ステータス",
|
||||
"status.popover.tools.trigger": "ツール",
|
||||
"status.popover.tools.mcp.description": "opencode.json に MCP サーバーを追加",
|
||||
"status.popover.tools.lsp.description": "ファイルを開くと LSP が自動検出されます",
|
||||
"status.popover.tools.plugins.description": "opencode.json にプラグインを追加",
|
||||
"status.popover.tools.mcp.empty": "MCP はまだ設定されていません",
|
||||
"status.popover.tools.lsp.empty": "アクティブな言語サーバーはまだありません",
|
||||
"status.popover.tools.plugins.empty": "プラグインはまだ設定されていません",
|
||||
"status.popover.tools.reauthenticate": "再認証",
|
||||
"status.popover.ariaLabel": "サーバー設定",
|
||||
"status.popover.tab.servers": "サーバー",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -736,8 +729,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "デスクトップのタイトルバーにターミナルボタンを表示します",
|
||||
"settings.general.row.showStatus.title": "サーバーステータス",
|
||||
"settings.general.row.showStatus.description": "タイトルバーにサーバーステータスボタンを表示します",
|
||||
"settings.general.row.showTools.title": "ツールメニュー",
|
||||
"settings.general.row.showTools.description": "タイトルバーに MCP、LSP、プラグインへのクイックアクセスを表示します",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "下部ナビゲーション",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"モバイルではタイトルバーとセッションタブを画面下部に配置します",
|
||||
@@ -751,9 +742,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします",
|
||||
"settings.general.row.newInterface.title": "新しいレイアウト",
|
||||
"settings.general.row.newInterface.badge": "新機能",
|
||||
"settings.general.row.newInterface.description":
|
||||
"新しいタブとホーム画面のレイアウトを使用します。期間限定でレイアウトを切り替えられます。",
|
||||
"settings.general.row.newInterfaceNotice.title": "新しいレイアウトを使用しています",
|
||||
"settings.general.row.newInterfaceNotice.description": "以前のレイアウトは利用できなくなりました",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "閉じる",
|
||||
"settings.general.row.pinchZoom.title": "ピンチでズーム",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",
|
||||
@@ -946,8 +941,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}} に到達できませんでした",
|
||||
"app.server.retrying": "自動的に再試行中...",
|
||||
"app.server.status.reconnecting": "{{server}} に再接続しています...",
|
||||
"app.server.status.disconnected": "{{server}} に接続できませんでした",
|
||||
"app.server.otherServers": "その他のサーバー",
|
||||
"dialog.server.add.usernamePlaceholder": "ユーザー名",
|
||||
"dialog.server.add.passwordPlaceholder": "パスワード",
|
||||
|
||||
@@ -82,6 +82,7 @@ export const dict = {
|
||||
"command.session.unshare": "세션 공유 중지",
|
||||
"command.session.unshare.description": "이 세션 공유 중지",
|
||||
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
|
||||
"palette.search.placeholder.home": "명령어 및 세션 검색",
|
||||
"palette.empty": "결과 없음",
|
||||
"palette.group.commands": "명령어",
|
||||
"palette.group.files": "파일",
|
||||
@@ -503,14 +504,6 @@ export const dict = {
|
||||
"session.header.open.menu": "열기 옵션",
|
||||
"session.header.open.copyPath": "경로 복사",
|
||||
"status.popover.trigger": "상태",
|
||||
"status.popover.tools.trigger": "도구",
|
||||
"status.popover.tools.mcp.description": "opencode.json에 MCP 서버 추가",
|
||||
"status.popover.tools.lsp.description": "파일을 열면 LSP가 자동으로 감지됩니다",
|
||||
"status.popover.tools.plugins.description": "opencode.json에 플러그인 추가",
|
||||
"status.popover.tools.mcp.empty": "아직 구성된 MCP가 없습니다",
|
||||
"status.popover.tools.lsp.empty": "아직 활성 언어 서버가 없습니다",
|
||||
"status.popover.tools.plugins.empty": "아직 구성된 플러그인이 없습니다",
|
||||
"status.popover.tools.reauthenticate": "다시 인증",
|
||||
"status.popover.ariaLabel": "서버 구성",
|
||||
"status.popover.tab.servers": "서버",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -790,8 +783,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.retrying": "자동으로 재시도 중...",
|
||||
"app.server.status.reconnecting": "{{server}}에 다시 연결하는 중...",
|
||||
"app.server.status.disconnected": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.otherServers": "다른 서버",
|
||||
"dialog.server.add.usernamePlaceholder": "사용자 이름",
|
||||
"dialog.server.add.passwordPlaceholder": "비밀번호",
|
||||
@@ -1005,14 +996,17 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "데스크톱 제목 표시줄에 터미널 버튼 표시",
|
||||
"settings.general.row.showStatus.title": "서버 상태",
|
||||
"settings.general.row.showStatus.description": "제목 표시줄에 서버 상태 버튼 표시",
|
||||
"settings.general.row.showTools.title": "도구 메뉴",
|
||||
"settings.general.row.showTools.description": "제목 표시줄에 MCP, LSP 및 플러그인 빠른 접근 표시",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "하단 탐색",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
|
||||
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
|
||||
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
|
||||
"settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인",
|
||||
"settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화",
|
||||
"settings.general.row.newInterface.title": "새 레이아웃",
|
||||
"settings.general.row.newInterface.badge": "신규",
|
||||
"settings.general.row.newInterface.description":
|
||||
"새 탭과 홈 화면 레이아웃을 사용합니다. 제한된 기간 동안 레이아웃을 전환할 수 있습니다.",
|
||||
"settings.general.row.newInterfaceNotice.title": "이제 새 레이아웃을 사용 중입니다",
|
||||
"settings.general.row.newInterfaceNotice.description": "이전 레이아웃은 더 이상 사용할 수 없습니다",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "닫기",
|
||||
"settings.general.row.pinchZoom.title": "핀치 줌",
|
||||
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
|
||||
"settings.updates.action.downloading": "다운로드 중...",
|
||||
|
||||
@@ -92,6 +92,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Slutt å dele denne sesjonen",
|
||||
|
||||
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
|
||||
"palette.search.placeholder.home": "Søk i kommandoer og sesjoner",
|
||||
"palette.empty": "Ingen resultater funnet",
|
||||
"palette.group.commands": "Kommandoer",
|
||||
"palette.group.files": "Filer",
|
||||
@@ -567,14 +568,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopier bane",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Verktøy",
|
||||
"status.popover.tools.mcp.description": "Legg til MCP-servere i opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP-er oppdages automatisk når du åpner filer",
|
||||
"status.popover.tools.plugins.description": "Legg til plugins i opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Ingen MCP-er konfigurert ennå",
|
||||
"status.popover.tools.lsp.empty": "Ingen aktive språkservere ennå",
|
||||
"status.popover.tools.plugins.empty": "Ingen plugins konfigurert ennå",
|
||||
"status.popover.tools.reauthenticate": "Autentiser på nytt",
|
||||
"status.popover.ariaLabel": "Serverkonfigurasjoner",
|
||||
"status.popover.tab.servers": "Servere",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -877,8 +870,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Kunne ikke nå {{server}}",
|
||||
"app.server.retrying": "Prøver på nytt automatisk...",
|
||||
"app.server.status.reconnecting": "Kobler til {{server}} på nytt...",
|
||||
"app.server.status.disconnected": "Kunne ikke koble til {{server}}",
|
||||
"app.server.otherServers": "Andre servere",
|
||||
"dialog.server.add.usernamePlaceholder": "brukernavn",
|
||||
"dialog.server.add.passwordPlaceholder": "passord",
|
||||
@@ -1100,16 +1091,18 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Vis terminalknappen på tittellinjen i skrivebordsappen",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Vis serverstatusknappen på tittellinjen",
|
||||
"settings.general.row.showTools.title": "Verktøymeny",
|
||||
"settings.general.row.showTools.description": "Vis hurtigtilgang til MCP-er, LSP-er og plugins på tittellinjen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigasjon nederst",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
|
||||
"settings.general.row.showCustomAgents.title": "Egendefinerte agenter",
|
||||
"settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet",
|
||||
"settings.general.row.newLayoutDesigns.title": "Ny layout og utforming",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet",
|
||||
"settings.general.row.newInterface.title": "Nytt oppsett",
|
||||
"settings.general.row.newInterface.badge": "Ny",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Bruk de nye fanene og startsidens oppsett. I en begrenset periode kan du bytte mellom oppsettene.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Du bruker nå det nye oppsettet",
|
||||
"settings.general.row.newInterfaceNotice.description": "Det forrige oppsettet er ikke lenger tilgjengelig",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Avvis",
|
||||
"settings.general.row.pinchZoom.title": "Knip for å zoome",
|
||||
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
|
||||
"settings.updates.action.downloading": "Laster ned...",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "Przestań udostępniać sesję",
|
||||
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
|
||||
"palette.search.placeholder": "Szukaj plików, poleceń i sesji",
|
||||
"palette.search.placeholder.home": "Szukaj poleceń i sesji",
|
||||
"palette.empty": "Brak wyników",
|
||||
"palette.group.commands": "Polecenia",
|
||||
"palette.group.files": "Pliki",
|
||||
@@ -627,14 +628,6 @@ export const dict = {
|
||||
"session.header.open.menu": "Opcje otwierania",
|
||||
"session.header.open.copyPath": "Kopiuj ścieżkę",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Narzędzia",
|
||||
"status.popover.tools.mcp.description": "Dodaj serwery MCP w opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP są wykrywane automatycznie podczas otwierania plików",
|
||||
"status.popover.tools.plugins.description": "Dodaj wtyczki w opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Nie skonfigurowano jeszcze MCP",
|
||||
"status.popover.tools.lsp.empty": "Brak aktywnych serwerów językowych",
|
||||
"status.popover.tools.plugins.empty": "Nie skonfigurowano jeszcze wtyczek",
|
||||
"status.popover.tools.reauthenticate": "Uwierzytelnij ponownie",
|
||||
"status.popover.ariaLabel": "Konfiguracje serwerów",
|
||||
"status.popover.tab.servers": "Serwery",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -741,8 +734,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Pokaż przycisk terminala na pasku tytułu aplikacji komputerowej",
|
||||
"settings.general.row.showStatus.title": "Status serwera",
|
||||
"settings.general.row.showStatus.description": "Pokaż przycisk statusu serwera na pasku tytułu",
|
||||
"settings.general.row.showTools.title": "Menu narzędzi",
|
||||
"settings.general.row.showTools.description": "Pokaż szybki dostęp do MCP, LSP i wtyczek na pasku tytułu",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Dolna nawigacja",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Umieść pasek tytułu i karty sesji u dołu ekranu na urządzeniach mobilnych",
|
||||
@@ -756,9 +747,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji",
|
||||
"settings.general.row.newInterface.title": "Nowy układ",
|
||||
"settings.general.row.newInterface.badge": "Nowość",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Używaj nowych kart i układu strony głównej. Przez ograniczony czas możesz przełączać się między układami.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Korzystasz teraz z nowego układu",
|
||||
"settings.general.row.newInterfaceNotice.description": "Poprzedni układ nie jest już dostępny",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Odrzuć",
|
||||
"settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl",
|
||||
@@ -952,8 +947,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Nie można połączyć z {{server}}",
|
||||
"app.server.retrying": "Ponawianie automatycznie...",
|
||||
"app.server.status.reconnecting": "Ponowne łączenie z {{server}}...",
|
||||
"app.server.status.disconnected": "Nie można połączyć z {{server}}",
|
||||
"app.server.otherServers": "Inne serwery",
|
||||
"dialog.server.add.usernamePlaceholder": "nazwa użytkownika",
|
||||
"dialog.server.add.passwordPlaceholder": "hasło",
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Прекратить публикацию сессии",
|
||||
|
||||
"palette.search.placeholder": "Поиск файлов, команд и сессий",
|
||||
"palette.search.placeholder.home": "Поиск команд и сессий",
|
||||
"palette.empty": "Ничего не найдено",
|
||||
"palette.group.commands": "Команды",
|
||||
"palette.group.files": "Файлы",
|
||||
@@ -683,14 +684,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Копировать путь",
|
||||
|
||||
"status.popover.trigger": "Статус",
|
||||
"status.popover.tools.trigger": "Инструменты",
|
||||
"status.popover.tools.mcp.description": "Добавьте серверы MCP в opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP определяются автоматически при открытии файлов",
|
||||
"status.popover.tools.plugins.description": "Добавьте плагины в opencode.json",
|
||||
"status.popover.tools.mcp.empty": "MCP пока не настроены",
|
||||
"status.popover.tools.lsp.empty": "Активных языковых серверов пока нет",
|
||||
"status.popover.tools.plugins.empty": "Плагины пока не настроены",
|
||||
"status.popover.tools.reauthenticate": "Повторно авторизоваться",
|
||||
"status.popover.ariaLabel": "Настройки серверов",
|
||||
"status.popover.tab.servers": "Серверы",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -806,8 +799,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Показывать кнопку терминала в строке заголовка приложения",
|
||||
"settings.general.row.showStatus.title": "Состояние сервера",
|
||||
"settings.general.row.showStatus.description": "Показывать кнопку состояния сервера в строке заголовка",
|
||||
"settings.general.row.showTools.title": "Меню инструментов",
|
||||
"settings.general.row.showTools.description": "Показывать быстрый доступ к MCP, LSP и плагинам в строке заголовка",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Нижняя навигация",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"На мобильных устройствах размещать строку заголовка и вкладки сессии внизу экрана",
|
||||
@@ -822,9 +813,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии",
|
||||
"settings.general.row.newInterface.title": "Новая компоновка",
|
||||
"settings.general.row.newInterface.badge": "Новое",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Используйте новые вкладки и компоновку главной страницы. В течение ограниченного времени можно переключаться между вариантами компоновки.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Теперь вы используете новую компоновку",
|
||||
"settings.general.row.newInterfaceNotice.description": "Прежняя компоновка больше недоступна",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Закрыть",
|
||||
"settings.general.row.pinchZoom.title": "Масштабирование щипком",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl",
|
||||
@@ -1032,8 +1027,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Не удалось связаться с {{server}}",
|
||||
"app.server.retrying": "Автоматическая повторная попытка...",
|
||||
"app.server.status.reconnecting": "Повторное подключение к {{server}}...",
|
||||
"app.server.status.disconnected": "Не удалось подключиться к {{server}}",
|
||||
"app.server.otherServers": "Другие серверы",
|
||||
"dialog.server.add.usernamePlaceholder": "имя пользователя",
|
||||
"dialog.server.add.passwordPlaceholder": "пароль",
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
|
||||
|
||||
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
|
||||
"palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน",
|
||||
"palette.empty": "ไม่พบผลลัพธ์",
|
||||
"palette.group.commands": "คำสั่ง",
|
||||
"palette.group.files": "ไฟล์",
|
||||
@@ -679,14 +680,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "คัดลอกเส้นทาง",
|
||||
|
||||
"status.popover.trigger": "สถานะ",
|
||||
"status.popover.tools.trigger": "เครื่องมือ",
|
||||
"status.popover.tools.mcp.description": "เพิ่มเซิร์ฟเวอร์ MCP ใน opencode.json",
|
||||
"status.popover.tools.lsp.description": "ระบบจะตรวจพบ LSP อัตโนมัติเมื่อคุณเปิดไฟล์",
|
||||
"status.popover.tools.plugins.description": "เพิ่มปลั๊กอินใน opencode.json",
|
||||
"status.popover.tools.mcp.empty": "ยังไม่ได้กำหนดค่า MCP",
|
||||
"status.popover.tools.lsp.empty": "ยังไม่มีเซิร์ฟเวอร์ภาษาที่ใช้งานอยู่",
|
||||
"status.popover.tools.plugins.empty": "ยังไม่ได้กำหนดค่าปลั๊กอิน",
|
||||
"status.popover.tools.reauthenticate": "ยืนยันตัวตนอีกครั้ง",
|
||||
"status.popover.ariaLabel": "การกำหนดค่าเซิร์ฟเวอร์",
|
||||
"status.popover.tab.servers": "เซิร์ฟเวอร์",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -797,8 +790,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "แสดงปุ่มเทอร์มินัลในแถบชื่อเรื่องเดสก์ท็อป",
|
||||
"settings.general.row.showStatus.title": "สถานะเซิร์ฟเวอร์",
|
||||
"settings.general.row.showStatus.description": "แสดงปุ่มสถานะเซิร์ฟเวอร์ในแถบชื่อเรื่อง",
|
||||
"settings.general.row.showTools.title": "เมนูเครื่องมือ",
|
||||
"settings.general.row.showTools.description": "แสดงทางลัดไปยัง MCP, LSP และปลั๊กอินในแถบชื่อเรื่อง",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "การนำทางด้านล่าง",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"วางแถบชื่อเรื่องและแท็บเซสชันไว้ด้านล่างของหน้าจอบนอุปกรณ์เคลื่อนที่",
|
||||
@@ -811,9 +802,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่",
|
||||
"settings.general.row.newInterface.title": "เลย์เอาต์ใหม่",
|
||||
"settings.general.row.newInterface.badge": "ใหม่",
|
||||
"settings.general.row.newInterface.description":
|
||||
"ใช้แท็บใหม่และเลย์เอาต์หน้าแรก คุณสามารถสลับระหว่างเลย์เอาต์ได้ในช่วงเวลาจำกัด",
|
||||
"settings.general.row.newInterfaceNotice.title": "ขณะนี้คุณกำลังใช้เลย์เอาต์ใหม่",
|
||||
"settings.general.row.newInterfaceNotice.description": "เลย์เอาต์ก่อนหน้าไม่พร้อมใช้งานอีกต่อไป",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "ปิด",
|
||||
"settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม",
|
||||
"settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม",
|
||||
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
|
||||
@@ -1016,8 +1011,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "ไม่สามารถติดต่อ {{server}}",
|
||||
"app.server.retrying": "กำลังลองใหม่โดยอัตโนมัติ...",
|
||||
"app.server.status.reconnecting": "กำลังเชื่อมต่อกับ {{server}} อีกครั้ง...",
|
||||
"app.server.status.disconnected": "ไม่สามารถเชื่อมต่อกับ {{server}}",
|
||||
"app.server.otherServers": "เซิร์ฟเวอร์อื่น ๆ",
|
||||
"dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้",
|
||||
"dialog.server.add.passwordPlaceholder": "รหัสผ่าน",
|
||||
|
||||
@@ -97,6 +97,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Bu oturumun paylaşımını durdur",
|
||||
|
||||
"palette.search.placeholder": "Dosya, komut ve oturum ara",
|
||||
"palette.search.placeholder.home": "Komut ve oturum ara",
|
||||
"palette.empty": "Sonuç bulunamadı",
|
||||
"palette.group.commands": "Komutlar",
|
||||
"palette.group.files": "Dosyalar",
|
||||
@@ -689,14 +690,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Yolu kopyala",
|
||||
|
||||
"status.popover.trigger": "Durum",
|
||||
"status.popover.tools.trigger": "Araçlar",
|
||||
"status.popover.tools.mcp.description": "opencode.json dosyasına MCP sunucuları ekleyin",
|
||||
"status.popover.tools.lsp.description": "Dosyaları açtığınızda LSP'ler otomatik olarak algılanır",
|
||||
"status.popover.tools.plugins.description": "opencode.json dosyasına eklentiler ekleyin",
|
||||
"status.popover.tools.mcp.empty": "Henüz yapılandırılmış MCP yok",
|
||||
"status.popover.tools.lsp.empty": "Henüz etkin dil sunucusu yok",
|
||||
"status.popover.tools.plugins.empty": "Henüz yapılandırılmış eklenti yok",
|
||||
"status.popover.tools.reauthenticate": "Yeniden kimlik doğrula",
|
||||
"status.popover.ariaLabel": "Sunucu yapılandırmaları",
|
||||
"status.popover.tab.servers": "Sunucular",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -811,8 +804,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Masaüstü başlık çubuğunda terminal düğmesini göster",
|
||||
"settings.general.row.showStatus.title": "Sunucu durumu",
|
||||
"settings.general.row.showStatus.description": "Başlık çubuğunda sunucu durumu düğmesini göster",
|
||||
"settings.general.row.showTools.title": "Araçlar menüsü",
|
||||
"settings.general.row.showTools.description": "Başlık çubuğunda MCP, LSP ve eklentilere hızlı erişim göster",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Alt gezinme",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Mobil cihazlarda başlık çubuğunu ve oturum sekmelerini ekranın altına yerleştir",
|
||||
@@ -826,9 +817,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
"settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir",
|
||||
"settings.general.row.newInterface.title": "Yeni düzen",
|
||||
"settings.general.row.newInterface.badge": "Yeni",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Yeni sekmeleri ve ana sayfa düzenini kullanın. Sınırlı bir süre boyunca düzenler arasında geçiş yapabilirsiniz.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Artık yeni düzeni kullanıyorsunuz",
|
||||
"settings.general.row.newInterfaceNotice.description": "Önceki düzen artık kullanılamıyor",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Kapat",
|
||||
"settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver",
|
||||
@@ -1036,8 +1031,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}} sunucusuna ulaşılamadı",
|
||||
"app.server.retrying": "Otomatik olarak tekrar deneniyor...",
|
||||
"app.server.status.reconnecting": "{{server}} sunucusuna yeniden bağlanılıyor...",
|
||||
"app.server.status.disconnected": "{{server}} sunucusuna bağlanılamadı",
|
||||
"app.server.otherServers": "Diğer sunucular",
|
||||
"dialog.server.add.usernamePlaceholder": "kullanıcı adı",
|
||||
"dialog.server.add.passwordPlaceholder": "parola",
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Припинити поширення цієї сесії",
|
||||
|
||||
"palette.search.placeholder": "Пошук файлів, команд і сесій",
|
||||
"palette.search.placeholder.home": "Пошук команд і сесій",
|
||||
"palette.empty": "Результатів не знайдено",
|
||||
"palette.group.commands": "Команди",
|
||||
"palette.group.files": "Файли",
|
||||
@@ -333,8 +334,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Не вдалося досягти {{server}}",
|
||||
"app.server.retrying": "Автоматичне повторення...",
|
||||
"app.server.status.reconnecting": "Повторне підключення до {{server}}...",
|
||||
"app.server.status.disconnected": "Не вдалося підключитися до {{server}}",
|
||||
"app.server.otherServers": "Інші сервери",
|
||||
|
||||
"dialog.server.title": "Сервери",
|
||||
@@ -731,14 +730,6 @@ export const dict = {
|
||||
"session.header.open.app.sublimeText": "Sublime Text",
|
||||
|
||||
"status.popover.trigger": "Статус",
|
||||
"status.popover.tools.trigger": "Інструменти",
|
||||
"status.popover.tools.mcp.description": "Додайте сервери MCP в opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP визначаються автоматично під час відкриття файлів",
|
||||
"status.popover.tools.plugins.description": "Додайте плагіни в opencode.json",
|
||||
"status.popover.tools.mcp.empty": "MCP ще не налаштовано",
|
||||
"status.popover.tools.lsp.empty": "Активних мовних серверів ще немає",
|
||||
"status.popover.tools.plugins.empty": "Плагіни ще не налаштовано",
|
||||
"status.popover.tools.reauthenticate": "Повторно автентифікуватися",
|
||||
"status.popover.ariaLabel": "Конфігурації серверів",
|
||||
"status.popover.tab.servers": "Сервери",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -899,8 +890,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
|
||||
"settings.general.row.showStatus.title": "Статус сервера",
|
||||
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку",
|
||||
"settings.general.row.showTools.title": "Меню інструментів",
|
||||
"settings.general.row.showTools.description": "Показувати швидкий доступ до MCP, LSP і плагінів у заголовку",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Нижня навігація",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Розмістити заголовок і вкладки сесії внизу екрана на мобільних пристроях",
|
||||
@@ -914,9 +903,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
|
||||
"settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії",
|
||||
"settings.general.row.newInterface.title": "Новий макет",
|
||||
"settings.general.row.newInterface.badge": "Нове",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Використовуйте нові вкладки та макет головної сторінки. Протягом обмеженого часу можна перемикатися між макетами.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Тепер ви використовуєте новий макет",
|
||||
"settings.general.row.newInterfaceNotice.description": "Попередній макет більше недоступний",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Відхилити",
|
||||
"settings.general.row.pinchZoom.title": "Масштабування щипком",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl",
|
||||
|
||||
@@ -120,6 +120,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "停止分享此会话",
|
||||
|
||||
"palette.search.placeholder": "搜索文件、命令和会话",
|
||||
"palette.search.placeholder.home": "搜索命令和会话",
|
||||
"palette.empty": "未找到结果",
|
||||
"palette.group.commands": "命令",
|
||||
"palette.group.files": "文件",
|
||||
@@ -675,14 +676,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "复制路径",
|
||||
|
||||
"status.popover.trigger": "状态",
|
||||
"status.popover.tools.trigger": "工具",
|
||||
"status.popover.tools.mcp.description": "在 opencode.json 中添加 MCP 服务器",
|
||||
"status.popover.tools.lsp.description": "打开文件时会自动检测 LSP",
|
||||
"status.popover.tools.plugins.description": "在 opencode.json 中添加插件",
|
||||
"status.popover.tools.mcp.empty": "尚未配置 MCP",
|
||||
"status.popover.tools.lsp.empty": "尚无活动的语言服务器",
|
||||
"status.popover.tools.plugins.empty": "尚未配置插件",
|
||||
"status.popover.tools.reauthenticate": "重新认证",
|
||||
"status.popover.ariaLabel": "服务器配置",
|
||||
"status.popover.tab.servers": "服务器",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -794,8 +787,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "在桌面版标题栏中显示终端按钮",
|
||||
"settings.general.row.showStatus.title": "服务器状态",
|
||||
"settings.general.row.showStatus.description": "在标题栏中显示服务器状态按钮",
|
||||
"settings.general.row.showTools.title": "工具菜单",
|
||||
"settings.general.row.showTools.description": "在标题栏中显示 MCP、LSP 和插件的快捷入口",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "底部导航",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "在移动设备上将标题栏和会话标签页置于屏幕底部",
|
||||
"settings.general.row.showCustomAgents.title": "自定义智能体",
|
||||
@@ -806,8 +797,12 @@ export const dict = {
|
||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
|
||||
"settings.general.row.newLayoutDesigns.title": "新版布局和设计",
|
||||
"settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面",
|
||||
"settings.general.row.newInterface.title": "新布局",
|
||||
"settings.general.row.newInterface.badge": "新",
|
||||
"settings.general.row.newInterface.description": "使用新的标签页和主页布局。在有限时间内可在不同布局之间切换。",
|
||||
"settings.general.row.newInterfaceNotice.title": "你现在使用的是新布局",
|
||||
"settings.general.row.newInterfaceNotice.description": "之前的布局不再可用",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
|
||||
"settings.general.row.pinchZoom.title": "双指缩放",
|
||||
"settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
@@ -1009,8 +1004,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "无法连接到 {{server}}",
|
||||
"app.server.retrying": "正在自动重试...",
|
||||
"app.server.status.reconnecting": "正在重新连接到 {{server}}...",
|
||||
"app.server.status.disconnected": "无法连接到 {{server}}",
|
||||
"app.server.otherServers": "其他服务器",
|
||||
"dialog.server.add.usernamePlaceholder": "用户名",
|
||||
"dialog.server.add.passwordPlaceholder": "密码",
|
||||
|
||||
@@ -97,6 +97,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "停止分享此工作階段",
|
||||
|
||||
"palette.search.placeholder": "搜尋檔案、命令和工作階段",
|
||||
"palette.search.placeholder.home": "搜尋命令和工作階段",
|
||||
"palette.empty": "找不到結果",
|
||||
"palette.group.commands": "命令",
|
||||
"palette.group.files": "檔案",
|
||||
@@ -673,14 +674,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "複製路徑",
|
||||
|
||||
"status.popover.trigger": "狀態",
|
||||
"status.popover.tools.trigger": "工具",
|
||||
"status.popover.tools.mcp.description": "在 opencode.json 中新增 MCP 伺服器",
|
||||
"status.popover.tools.lsp.description": "開啟檔案時會自動偵測 LSP",
|
||||
"status.popover.tools.plugins.description": "在 opencode.json 中新增外掛程式",
|
||||
"status.popover.tools.mcp.empty": "尚未設定 MCP",
|
||||
"status.popover.tools.lsp.empty": "尚無作用中的語言伺服器",
|
||||
"status.popover.tools.plugins.empty": "尚未設定外掛程式",
|
||||
"status.popover.tools.reauthenticate": "重新驗證",
|
||||
"status.popover.ariaLabel": "伺服器設定",
|
||||
"status.popover.tab.servers": "伺服器",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -789,8 +782,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "在桌面標題列中顯示終端機按鈕",
|
||||
"settings.general.row.showStatus.title": "伺服器狀態",
|
||||
"settings.general.row.showStatus.description": "在標題列中顯示伺服器狀態按鈕",
|
||||
"settings.general.row.showTools.title": "工具選單",
|
||||
"settings.general.row.showTools.description": "在標題列中顯示 MCP、LSP 和外掛程式的快速入口",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "底部導覽",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "在行動裝置上將標題列和工作階段分頁置於畫面底部",
|
||||
"settings.general.row.showCustomAgents.title": "自訂代理程式",
|
||||
@@ -802,8 +793,12 @@ export const dict = {
|
||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
|
||||
"settings.general.row.newLayoutDesigns.title": "新版面與設計",
|
||||
"settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面",
|
||||
"settings.general.row.newInterface.title": "新版面",
|
||||
"settings.general.row.newInterface.badge": "新",
|
||||
"settings.general.row.newInterface.description": "使用新的分頁和首頁版面。在限定時間內可切換版面。",
|
||||
"settings.general.row.newInterfaceNotice.title": "你現在使用的是新版面",
|
||||
"settings.general.row.newInterfaceNotice.description": "先前的版面已無法使用",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
|
||||
"settings.general.row.pinchZoom.title": "雙指縮放",
|
||||
"settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
@@ -1005,8 +1000,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "無法連線至 {{server}}",
|
||||
"app.server.retrying": "正在自動重試...",
|
||||
"app.server.status.reconnecting": "正在重新連線至 {{server}}...",
|
||||
"app.server.status.disconnected": "無法連線至 {{server}}",
|
||||
"app.server.otherServers": "其他伺服器",
|
||||
"dialog.server.add.usernamePlaceholder": "使用者名稱",
|
||||
"dialog.server.add.passwordPlaceholder": "密碼",
|
||||
|
||||
@@ -3,6 +3,7 @@ export { useLayout } from "./context/layout"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServerSync } from "./context/server-sync"
|
||||
export { useServer } from "./context/server"
|
||||
export { useSettings } from "./context/settings"
|
||||
export { useTabs } from "./context/tabs"
|
||||
export { useProviders } from "./hooks/use-providers"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
|
||||
+214
-121
@@ -5,6 +5,7 @@ import {
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
@@ -35,7 +36,7 @@ import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
@@ -78,6 +79,23 @@ const HOME_SESSION_LIMIT = 64
|
||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||
|
||||
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
|
||||
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
|
||||
if (!(event.target instanceof Element)) return
|
||||
|
||||
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
|
||||
if (
|
||||
scrollable !== viewport &&
|
||||
scrollable &&
|
||||
(event.deltaY < 0
|
||||
? scrollable.scrollTop > 0
|
||||
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
|
||||
)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_ROW_LAYOUT =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||
@@ -150,6 +168,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
let content: HTMLDivElement | undefined
|
||||
let positionFrame: number | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
|
||||
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||
const [state, setState] = createStore({
|
||||
@@ -208,6 +227,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
|
||||
function updatePositionCache() {
|
||||
if (!viewport) return
|
||||
const header = groups()
|
||||
.map((group) => headerRefs.get(group.id))
|
||||
.find((el) => el !== undefined)
|
||||
if (header && typeof getComputedStyle === "function") {
|
||||
const top = Number.parseFloat(getComputedStyle(header).top)
|
||||
if (Number.isFinite(top)) stickyTop = top
|
||||
}
|
||||
groups().forEach((group) => {
|
||||
const el = headerRefs.get(group.id)
|
||||
if (!el) return
|
||||
@@ -223,7 +249,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
.slice(index + 1)
|
||||
.map((item) => headerOffsets.get(item.id))
|
||||
.find((offset) => offset !== undefined)
|
||||
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||
const opacity =
|
||||
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||
@@ -275,8 +301,11 @@ export function NewHome() {
|
||||
const command = useCommand()
|
||||
const notification = useNotification()
|
||||
const marked = useMarked()
|
||||
const openSettings = useSettingsDialog()
|
||||
const openSettings = useSettingsCommand()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
let sessionViewport: HTMLDivElement | undefined
|
||||
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
|
||||
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
searchFocused: false,
|
||||
@@ -431,6 +460,34 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
command.register("home", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
|
||||
void dialog.show(() => (
|
||||
<DialogHomeCommandPaletteV2
|
||||
server={conn}
|
||||
onSelectSession={(entry) => {
|
||||
if (!entry.sessionID || !entry.directory || !entry.server) return
|
||||
const sessionID = entry.sessionID
|
||||
const server = entry.server
|
||||
const directory = entry.project?.worktree ?? entry.directory
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
|
||||
tabs.select(tab)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home.sessions.search.focus",
|
||||
title: searchPlaceholder(),
|
||||
@@ -576,127 +633,158 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
const next = closeHomeProject(
|
||||
selection(),
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
}}
|
||||
clearNotifications={clearNotifications}
|
||||
unseenCount={unseenCount}
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<section
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={searchPlaceholder()}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
focusSessionSearch = focus
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<ScrollView
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={sessionThumbTrack}
|
||||
thumbHoverTarget={sessionHoverTarget}
|
||||
viewportRef={(el) => {
|
||||
sessionViewport = el
|
||||
sessionHeaderOpacity.setViewport(el)
|
||||
}}
|
||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||
onWheel={(event) => {
|
||||
if (!sessionViewport) return
|
||||
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
|
||||
containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
>
|
||||
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
const next = closeHomeProject(
|
||||
selection(),
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
}}
|
||||
clearNotifications={clearNotifications}
|
||||
unseenCount={unseenCount}
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
onWheel={(event) => {
|
||||
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
onInput={(value) => setState("search", value)}
|
||||
onFocus={() => setState("searchFocused", true)}
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView
|
||||
class="mt-3 -mr-3 min-h-0 flex-1 relative"
|
||||
viewportRef={sessionHeaderOpacity.setViewport}
|
||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||
|
||||
<section
|
||||
ref={setSessionHoverTarget}
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<Show when={groups().length > 0 && newSessionProject()}>
|
||||
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={openNewSession}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
|
||||
onWheel={(event) => {
|
||||
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={searchPlaceholder()}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
focusSessionSearch = focus
|
||||
}}
|
||||
onInput={(value) => setState("search", value)}
|
||||
onFocus={() => setState("searchFocused", true)}
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<Show when={groups().length > 0 && newSessionProject()}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={openNewSession}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
ref={setSessionThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -718,6 +806,7 @@ function HomeProjectColumn(props: {
|
||||
openSettings: () => void
|
||||
openHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
onWheel: (event: WheelEvent) => void
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
@@ -734,8 +823,12 @@ function HomeProjectColumn(props: {
|
||||
|
||||
return (
|
||||
<aside
|
||||
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
|
||||
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
onWheel={(event) => {
|
||||
if (event.target === event.currentTarget) return
|
||||
props.onWheel(event)
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
@@ -830,7 +923,7 @@ function HomeUtilityNav(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
@@ -1461,7 +1554,7 @@ function HomeSessionGroupHeader(props: {
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||
>
|
||||
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||
{props.title}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
useSettingsCommand()
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
|
||||
@@ -40,7 +38,6 @@ export default function NewLayout(props: ParentProps) {
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar inline />}
|
||||
<TabsInfoPopup />
|
||||
<HelpButton />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
@@ -2396,7 +2396,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
<TabsInfoPopup />
|
||||
<HelpButton />
|
||||
<ToastRegion v2={false} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
|
||||
const global = useGlobal()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const permissionState = createMemo(() => permission.ensureServerState(server()))
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const sync = createMemo(() => {
|
||||
const conn = connection()
|
||||
@@ -23,7 +24,7 @@ export function useSessionTabAvatarState(
|
||||
if (!serverSync) return false
|
||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
return !permissionState().autoResponds(item, directory())
|
||||
})
|
||||
})
|
||||
const hasQuestions = createMemo(() => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
PromptProjectSelector,
|
||||
@@ -22,9 +26,10 @@ import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import createPresence from "solid-presence"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -47,9 +52,11 @@ export default function NewSessionPage() {
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const openProviderSettings = useSettingsDialog("providers")
|
||||
useSettingsCommand()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const local = useLocal()
|
||||
@@ -72,6 +79,15 @@ export default function NewSessionPage() {
|
||||
})
|
||||
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
@@ -82,6 +98,7 @@ export default function NewSessionPage() {
|
||||
])
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
@@ -121,6 +138,17 @@ export default function NewSessionPage() {
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={settings.visibility.status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
|
||||
@@ -41,13 +41,13 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { ErrorPage } from "@/pages/error"
|
||||
import { CommentsProvider, useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
@@ -58,6 +58,7 @@ import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { promptLength } from "@/components/prompt-input/history"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
@@ -155,13 +156,25 @@ export function SessionPage() {
|
||||
// workspace-scoped state (terminal, directory providers) lives below.
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const serverSync = useServerSync()
|
||||
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||
// when session content falls back to the route error boundary.
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionSettingsCommand() {
|
||||
useSettingsCommand()
|
||||
return null
|
||||
}
|
||||
|
||||
export function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||
) {
|
||||
@@ -250,19 +263,17 @@ function ResolvedTargetSessionRoute() {
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
{/* Non-keyed: closes only while the target's directory is unknown (uncached
|
||||
lineage mid-resolution), which tears down the workspace subtree including
|
||||
the terminal. Same-workspace tab switches keep it open because warm
|
||||
targets resolve synchronously from the sync cache. */}
|
||||
<Show when={directory()}>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
// Non-keyed: closes only while the target's directory is unknown (uncached
|
||||
// lineage mid-resolution), which tears down the workspace subtree including
|
||||
// the terminal. Same-workspace tab switches keep it open because warm
|
||||
// targets resolve synchronously from the sync cache.
|
||||
<Show when={directory()}>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,10 +294,10 @@ function TargetServerScopedProviders(
|
||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||
) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -352,6 +363,7 @@ export default function Page() {
|
||||
const platform = usePlatform()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const command = useCommand()
|
||||
const terminal = useTerminal()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
@@ -1125,6 +1137,14 @@ export default function Page() {
|
||||
review: reviewTab,
|
||||
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
|
||||
})
|
||||
command.register("session-palette", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: () => command.trigger("file.open", "palette"),
|
||||
},
|
||||
])
|
||||
|
||||
const openReviewFile = createOpenReviewFile({
|
||||
showAllFiles,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createPromptInputController(input: {
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
visible: settings.visibility.customAgents(),
|
||||
visible: local.agent.visible(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
|
||||
@@ -757,7 +757,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
||||
commentsUi.onLineNumberSelectionEnd(range)
|
||||
}}
|
||||
search={search}
|
||||
class="select-text"
|
||||
class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]"
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
|
||||
@@ -28,7 +28,6 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useSDK()
|
||||
@@ -46,6 +45,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
let root: HTMLDivElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
|
||||
onCleanup(() => terminal.cancelFocus())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
@@ -94,36 +95,12 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
),
|
||||
)
|
||||
|
||||
const focus = (id: string) => {
|
||||
focusTerminalById(id)
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
|
||||
const timers = delays.map((ms) =>
|
||||
window.setTimeout(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
}, ms),
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [opened(), terminal.active()] as const,
|
||||
([next, id]) => {
|
||||
if (!next || !id) return
|
||||
const stop = focus(id)
|
||||
onCleanup(stop)
|
||||
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
|
||||
([next, id, requested]) => {
|
||||
if (!next || !id || !requested) return
|
||||
focusTerminalById(id)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -285,7 +262,15 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
|
||||
>
|
||||
<Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => (
|
||||
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||
@@ -304,7 +289,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -326,7 +311,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
@@ -344,7 +329,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
autoFocus={terminal.focusRequested(id)}
|
||||
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||
class="!px-[14px]"
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onCleanup={ops.update}
|
||||
|
||||
@@ -38,6 +38,8 @@ export function TerminalPanel() {
|
||||
const close = () => view().terminal.close()
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onCleanup(() => terminal.cancelFocus())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
activeDraggable: undefined as string | undefined,
|
||||
@@ -285,7 +287,7 @@ export function TerminalPanel() {
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
onClick={() => terminal.new()}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -303,6 +305,7 @@ export function TerminalPanel() {
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onCleanup={ops.update}
|
||||
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
@@ -408,6 +409,7 @@ export function MessageTimeline(props: {
|
||||
return timelineRows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementOffset: observeElementOffsetReconnectAware,
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
const child = document.createElement("div")
|
||||
const sibling = document.createElement("div")
|
||||
route.append(viewport)
|
||||
viewport.append(child)
|
||||
|
||||
expect(mutationNodesContainElement([viewport], viewport)).toBe(true)
|
||||
expect(mutationNodesContainElement([route], viewport)).toBe(true)
|
||||
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
|
||||
})
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
const unrelated = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 0,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: [number, boolean][] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
|
||||
calls.push([offset, isScrolling])
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 20,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: number[] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => {
|
||||
calls.push(offset)
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(1)
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
|
||||
instance.scrollOffset = 79_400
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await frames(3)
|
||||
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
expect(calls).toEqual([0, 0])
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ name: "LTR", isRtl: false, expected: 240 },
|
||||
{ name: "RTL", isRtl: true, expected: -240 },
|
||||
])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
viewport.scrollLeft = 240
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 0,
|
||||
options: {
|
||||
horizontal: true,
|
||||
isRtl,
|
||||
isScrollingResetDelay: 0,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: [number, boolean][] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
|
||||
calls.push([offset, isScrolling])
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
|
||||
expect(calls).toEqual([[expected, false]])
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test("cleanup suppresses an already queued delegated offset callback", async () => {
|
||||
const viewport = document.createElement("div")
|
||||
document.body.append(viewport)
|
||||
viewport.scrollTop = 100
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 0,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 10,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: [number, boolean][] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) =>
|
||||
calls.push([offset, isScrolling]),
|
||||
)
|
||||
|
||||
viewport.dispatchEvent(new Event("scroll"))
|
||||
cleanup?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
|
||||
expect(calls).toEqual([[100, true]])
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
test("cleanup cancels reconnect checks and delegated offset observation", async () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 0,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 50,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: number[] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset))
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
cleanup?.()
|
||||
instance.scrollOffset = 100
|
||||
viewport.dispatchEvent(new Event("scroll"))
|
||||
await frames(4)
|
||||
|
||||
expect(calls).toEqual([])
|
||||
route.remove()
|
||||
})
|
||||
|
||||
async function frames(count: number) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
|
||||
|
||||
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
|
||||
instance: Virtualizer<TScrollElement, TItemElement>,
|
||||
callback: (offset: number, isScrolling: boolean) => void,
|
||||
) {
|
||||
let active = true
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
if (!active) return
|
||||
callback(offset, isScrolling)
|
||||
}
|
||||
const cleanupOffset = observeElementOffset(instance, deliver)
|
||||
const element = instance.scrollElement
|
||||
const targetWindow = instance.targetWindow
|
||||
const root = element?.closest("main") ?? element?.ownerDocument.body
|
||||
if (!element || !targetWindow || !root)
|
||||
return () => {
|
||||
active = false
|
||||
cleanupOffset?.()
|
||||
}
|
||||
|
||||
let removed = false
|
||||
let frame: number | undefined
|
||||
const clearCheck = () => {
|
||||
if (frame === undefined) return
|
||||
targetWindow.cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
const startCheck = () => {
|
||||
clearCheck()
|
||||
const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay
|
||||
let framesAfterDeadline = 0
|
||||
const check = (time: number) => {
|
||||
frame = undefined
|
||||
if (element.isConnected) {
|
||||
const offset = instance.options.horizontal
|
||||
? element.scrollLeft * (instance.options.isRtl ? -1 : 1)
|
||||
: element.scrollTop
|
||||
if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false)
|
||||
}
|
||||
if (time >= deadline) framesAfterDeadline += 1
|
||||
if (framesAfterDeadline >= 2) return
|
||||
frame = targetWindow.requestAnimationFrame(check)
|
||||
}
|
||||
frame = targetWindow.requestAnimationFrame(check)
|
||||
}
|
||||
const observer = new targetWindow.MutationObserver((records) => {
|
||||
if (!active) return
|
||||
records.forEach((record) => {
|
||||
if (record.target === element || element.contains(record.target)) return
|
||||
if (mutationNodesContainElement(record.removedNodes, element)) {
|
||||
removed = true
|
||||
clearCheck()
|
||||
}
|
||||
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
|
||||
removed = false
|
||||
startCheck()
|
||||
})
|
||||
})
|
||||
// Session routes are replaced below persistent main; body is the fallback for isolated hosts.
|
||||
observer.observe(root, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
observer.disconnect()
|
||||
clearCheck()
|
||||
cleanupOffset?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function mutationNodesContainElement(nodes: Iterable<Node>, element: Element) {
|
||||
return [...nodes].some((node) => node === element || node.contains(element))
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
@@ -19,7 +18,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const settings = useSettings()
|
||||
const { sessionKey } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const model = input.model ?? local.model
|
||||
@@ -70,7 +68,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
disabled: !local.agent.visible(),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
@@ -78,7 +76,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
disabled: !local.agent.visible(),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -264,7 +264,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const openTerminal = () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
if (terminal.all().length > 0) terminal.new({ focus: true })
|
||||
if (terminal.all().length === 0) terminal.requestFocus()
|
||||
view().terminal.open()
|
||||
}
|
||||
|
||||
@@ -467,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: openFile,
|
||||
}),
|
||||
@@ -498,7 +499,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
onSelect: () => {
|
||||
if (view().terminal.opened()) {
|
||||
terminal.cancelFocus()
|
||||
view().terminal.close()
|
||||
return
|
||||
}
|
||||
terminal.requestFocus(terminal.active())
|
||||
view().terminal.open()
|
||||
},
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export function createReviewPanelV2State() {
|
||||
const [store, setStore] = persisted(
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.global("review-panel-v2"),
|
||||
createStore({
|
||||
sidebarOpened: true,
|
||||
@@ -24,6 +24,7 @@ export function createReviewPanelV2State() {
|
||||
return {
|
||||
sidebarOpened: () => store.sidebarOpened,
|
||||
sidebarWidth: () => store.sidebarWidth,
|
||||
sidebarTransition: ready,
|
||||
filter,
|
||||
setFilter,
|
||||
expandMode: () => store.expandMode,
|
||||
|
||||
@@ -200,6 +200,7 @@ function ReviewPanelV2Sidebar(props: {
|
||||
return (
|
||||
<SessionReviewV2Sidebar
|
||||
open={props.state.sidebarOpened()}
|
||||
transition={props.state.sidebarTransition()}
|
||||
title={props.title}
|
||||
stats={<DiffChanges changes={props.diffs()} />}
|
||||
filter={props.state.filter()}
|
||||
|
||||
@@ -19,6 +19,7 @@ const emptyFiles: string[] = []
|
||||
export type SessionFileBrowserState = {
|
||||
sidebarOpened: () => boolean
|
||||
sidebarWidth: () => number
|
||||
sidebarTransition: () => boolean
|
||||
resizeSidebar: (width: number) => void
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
@@ -96,6 +97,7 @@ export function SessionFileBrowserTab(props: {
|
||||
sidebar={
|
||||
<SessionReviewV2Sidebar
|
||||
open={sidebarOpened()}
|
||||
transition={props.state.sidebarTransition()}
|
||||
title={<span class="truncate">{title()}</span>}
|
||||
filter={filter()}
|
||||
onFilterChange={setFilter}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createServerSessionEntries } from "@/components/command-palette"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
|
||||
const stored: Project = {
|
||||
id: "project-1",
|
||||
name: "Palette project",
|
||||
worktree: "/workspace/project",
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
|
||||
const session: GlobalSession = {
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: stored.id,
|
||||
directory: stored.worktree,
|
||||
title: "Palette session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 2 },
|
||||
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
|
||||
}
|
||||
|
||||
describe("command palette sessions", () => {
|
||||
test("uses the home project avatar and cancels superseded searches", async () => {
|
||||
const server = ServerConnection.Key.make("selected-server")
|
||||
const opened: LocalProject = {
|
||||
...stored,
|
||||
icon: { override: "home-project-avatar" },
|
||||
expanded: true,
|
||||
}
|
||||
const searches: string[] = []
|
||||
const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
|
||||
(resolve, reject) => {
|
||||
createRoot((dispose) => {
|
||||
const search = createServerSessionEntries({
|
||||
server,
|
||||
opened: () => [opened],
|
||||
stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }],
|
||||
load: async (text) => {
|
||||
searches.push(text)
|
||||
return {
|
||||
data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
|
||||
}
|
||||
},
|
||||
untitled: () => "Untitled",
|
||||
category: () => "Sessions",
|
||||
})
|
||||
const first = search("palette")
|
||||
const second = search("palette session")
|
||||
Promise.all([first, second])
|
||||
.then(([cancelled, entries]) => {
|
||||
expect(cancelled).toEqual([])
|
||||
resolve(entries)
|
||||
})
|
||||
.catch(reject)
|
||||
.finally(dispose)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
expect(searches).toEqual(["palette session"])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
|
||||
expect(result[0]).toMatchObject({
|
||||
server,
|
||||
sessionID: session.id,
|
||||
description: stored.name,
|
||||
project: { id: stored.id, icon: opened.icon },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeAll, expect, mock, test } from "bun:test"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
|
||||
let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State
|
||||
let read: ((value: string | null) => void) | undefined
|
||||
|
||||
const storage: AsyncStorage = {
|
||||
getItem: () => new Promise((resolve) => (read = resolve)),
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
key: async () => null,
|
||||
getLength: async () => 0,
|
||||
length: Promise.resolve(0),
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@opencode-ai/session-ui/v2/session-review-v2", () => ({
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT: 240,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN: 200,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480,
|
||||
}))
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
|
||||
}))
|
||||
|
||||
createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State
|
||||
})
|
||||
|
||||
test("enables sidebar motion only after custom width hydration", async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
createRoot((dispose) => {
|
||||
const state = createReviewPanelV2State()
|
||||
const transition =
|
||||
"sidebarTransition" in state && typeof state.sidebarTransition === "function"
|
||||
? (state.sidebarTransition as () => boolean)
|
||||
: undefined
|
||||
|
||||
try {
|
||||
expect(transition).toBeFunction()
|
||||
expect(transition?.()).toBeFalse()
|
||||
expect(state.sidebarWidth()).toBe(240)
|
||||
} catch (error) {
|
||||
dispose()
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!transition?.()) return
|
||||
try {
|
||||
expect(state.sidebarWidth()).toBe(360)
|
||||
dispose()
|
||||
resolve()
|
||||
} catch (error) {
|
||||
dispose()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
read?.(JSON.stringify({ sidebarOpened: true, sidebarWidth: 360, expandMode: "collapse" }))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.3",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user