Compare commits

..
Author SHA1 Message Date
usrnk1 4100018ec8 Merge remote-tracking branch 'origin/dev' into tools-menu
# Conflicts:
#	packages/app/src/components/status-popover.tsx
2026-07-14 11:14:28 +02:00
usrnk1 f69c29b38c fix(app): simplify server status indicators 2026-07-14 10:55:15 +02:00
usrnk1 11d2eb3ec8 feat(app): add server connection indicator 2026-07-14 10:33:01 +02:00
opencode-agent[bot] cb8be9ba12 chore: generate 2026-07-14 03:38:03 +00:00
15046658ba feat(app): update tabs intro content (#36701)
Co-authored-by: Jay <air@live.ca>
2026-07-14 11:36:22 +08:00
opencode-agent[bot] 7255ce9a57 chore: update nix node_modules hashes 2026-07-14 00:23:00 +00:00
opencode-agent[bot] 2b9cf5d388 chore: generate 2026-07-14 00:05:56 +00:00
Luke ParkerandGitHub a625d35f7b fix(app): preserve composer caret after requests (#36503) 2026-07-14 10:04:11 +10:00
Luke ParkerandGitHub 449c64928b fix(app): 78x faster Home cold loading (#36214) 2026-07-14 10:03:51 +10:00
Luke ParkerandGitHub 35c88c3fc8 fix(app): preserve timeline bottom anchoring (#36160) 2026-07-14 10:03:18 +10:00
Luke ParkerandGitHub b5e09024d5 fix(app): clarify status indicator severity (#36031) 2026-07-14 10:02:46 +10:00
Luke ParkerandGitHub 9a51765bd2 fix(ui): preserve code spans adjacent to tildes (#35835) 2026-07-14 10:02:30 +10:00
opencode bb31c9b92c sync release versions for v1.17.20 2026-07-13 21:09:53 +00:00
Aiden ClineandGitHub 5117679148 fix(opencode): remove obsolete Luna Responses Lite workaround (#36750) 2026-07-13 15:29:41 -05:00
usrnk1 014696db63 Wip commit 2026-07-10 14:09:37 +02:00
usrnk1 351172dd1b docs(app): clarify tools menu setting 2026-07-10 11:40:34 +02:00
usrnk1 50f8938960 fix(app): respect tools visibility setting 2026-07-10 11:33:54 +02:00
usrnk1 23bc29fde5 fix(app): preserve mcp switch transition 2026-07-10 11:10:48 +02:00
usrnk1 2a1565951f fix(app): avoid disabled cursor during mcp toggle 2026-07-10 11:08:23 +02:00
usrnk1 4db321512f fix(app): hide tools menu on new session 2026-07-10 11:05:10 +02:00
usrnk1 ecaaceeecf Merge branch 'dev' into tools-menu 2026-07-10 10:38:25 +02:00
usrnk1 249bbd599c fix(ui): prevent segmented label clipping 2026-07-09 16:04:49 +02:00
usrnk1 2aeeca5243 fix(ui): respect reduced motion in popovers 2026-07-09 16:04:49 +02:00
usrnk1 c8f4c383cd test(app): add tools menu stories 2026-07-09 16:04:41 +02:00
usrnk1 25ee2435f6 feat(app): replace status menu with tools 2026-07-09 16:04:35 +02:00
95 changed files with 1757 additions and 445 deletions
+36 -35
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -95,7 +95,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.19",
"version": "1.17.20",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -143,7 +143,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -157,7 +157,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -380,7 +380,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -448,7 +448,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"effect": "catalog:",
},
@@ -460,7 +460,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -558,7 +558,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.19",
"version": "1.17.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -689,7 +689,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -780,7 +780,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -795,7 +795,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -839,7 +839,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -886,7 +886,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"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.19",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -973,7 +973,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1024,7 +1024,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1067,6 +1067,7 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -1074,10 +1075,8 @@
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
},
"overrides": {
@@ -1113,7 +1112,7 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28",
"@tanstack/solid-virtual": "3.13.32",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
@@ -1133,7 +1132,7 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
@@ -2781,9 +2780,9 @@
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
@@ -4313,7 +4312,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
@@ -6051,6 +6050,8 @@
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=",
"aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=",
"aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=",
"x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw="
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
}
}
+4 -5
View File
@@ -43,7 +43,7 @@
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.28",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@@ -152,10 +152,9 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
const transport = await installSseTransport(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
const draft = "keep the caret at the end"
await editor.fill(draft)
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
const cursor = draft.length - 4
await expect
.poll(() =>
editor.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(element)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}),
)
.toBe(cursor)
await transport.send({
directory,
payload: {
type: "question.asked",
properties: {
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(editor).toHaveCount(0)
await transport.send({
directory,
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
await page.keyboard.press("x")
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
})
async function mockServer(
page: Page,
requests: {
+29
View File
@@ -56,6 +56,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/api/session")
return json(route, {
data: config.sessions.map((session) => v2Session(session, config.directory)),
cursor: {},
})
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -132,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID ?? "project",
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: "created" in time && typeof time.created === "number" ? time.created : 0,
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
...(session.time && typeof session.time === "object" && "archived" in session.time
? { archived: session.time.archived }
: {}),
},
title: session.title ?? session.id,
location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
},
...(typeof session.path === "string" ? { subpath: session.path } : {}),
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.19",
"version": "1.17.20",
"description": "",
"type": "module",
"exports": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+28 -10
View File
@@ -5,6 +5,8 @@ import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
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 = (
@@ -65,7 +67,7 @@ export function TabsInfoPopup() {
<Show when={!state.dismissed}>
<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)]"
aria-label="Introducing Tabs. A faster, more intuitive way to work."
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
type="button"
@@ -107,7 +109,7 @@ export function TabsInfoPopup() {
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
A faster, more intuitive way to work.
Organize your work and active sessions with tabs
</p>
</div>
</button>
@@ -127,16 +129,32 @@ export function TabsInfoPopup() {
icon={<IconV2 name="xmark-small" />}
/>
</div>
<div class="relative flex w-full flex-col items-start gap-6 p-8">
<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">
Introducing Tabs Navigation.
</p>
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
sessions.
Introducing Tabs
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<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
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>
</Drawer>
+3 -1
View File
@@ -633,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
savedCursor = currentCursor()
const cursor = currentCursor()
savedCursor = cursor
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
closePopover()
setComposing(false)
}
@@ -0,0 +1,21 @@
[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);
}
}
@@ -0,0 +1,33 @@
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" />,
}
@@ -0,0 +1,17 @@
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()
})
})
@@ -0,0 +1,35 @@
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,3 +1,4 @@
// @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"
@@ -16,7 +17,9 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
@@ -33,6 +36,8 @@ 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",
@@ -144,6 +149,8 @@ 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()
@@ -165,6 +172,7 @@ 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,
@@ -234,9 +242,25 @@ 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>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
serverStatus: isDesktop() ? serverStatus() : undefined,
serverStatusLabel: serverStatusLabel(),
onServerStatusClick: openServerSettings,
toolsVisible: isDesktop() && status(),
statusLabel: language.t("status.popover.tools.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
@@ -517,7 +541,10 @@ export function SessionHeader() {
}
type SessionHeaderV2ActionsState = {
statusVisible: boolean
serverStatus?: ServerStatusIconState
serverStatusLabel: string
onServerStatusClick: () => void
toolsVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string[]
@@ -530,11 +557,26 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<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}>
<StatusPopoverV2 />
</Tooltip>
</TooltipV2>
</Show>
<Show when={props.state.reviewVisible}>
<TooltipV2
@@ -377,8 +377,8 @@ export const SettingsGeneralV2: Component<{
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
title={language.t("settings.general.row.showTools.title")}
description={language.t("settings.general.row.showTools.description")}
>
<div data-action="settings-show-status">
<Switch
@@ -16,6 +16,7 @@ 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)
@@ -280,7 +281,14 @@ 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)
@@ -289,6 +297,42 @@ 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)]">
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
})
})
describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
@@ -0,0 +1,19 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}
+31 -52
View File
@@ -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,6 +9,8 @@ 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 })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -19,16 +21,14 @@ export function StatusPopover() {
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
return (
<Popover
@@ -47,13 +47,11 @@ export function StatusPopover() {
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
issue: issue(),
})}`}
/>
</div>
}
@@ -87,22 +85,22 @@ function DirectoryStatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
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] : []
}),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
issue: issue(),
label: language.t("status.popover.tools.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
@@ -123,8 +121,8 @@ function ServerStatusPopover() {
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -140,9 +138,8 @@ function ServerStatusPopover() {
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
issue: boolean
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
@@ -161,16 +158,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@@ -191,15 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/>
</div>
}
trigger={<ToolsMenuIcon warning={props.state.issue} />}
{...popoverProps}
>
{props.state.body()}
@@ -0,0 +1,136 @@
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>
),
}
+202
View File
@@ -0,0 +1,202 @@
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",
}}
/>
)
}
@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
dispose()
}
})
test("keeps non-bootstrapping children passive until a real directory access", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
const bootstraps: string[] = []
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
bootstraps.push(directory)
},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
const queries = querySingles.slice(offset)
expect(queries).toHaveLength(6)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[3]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
expect(queries[5]?.().enabled).toBe(false)
expect(store.path.directory).toBe("/project")
expect(store.provider_ready).toBe(false)
expect(store.lsp_ready).toBe(false)
expect(bootstraps).toEqual([])
manager.child("/project")
expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
expect(queries[5]?.().enabled).toBe(true)
expect(bootstraps).toEqual(["/project"])
manager.child("/project", { bootstrap: false })
expect(queries[0]?.().enabled).toBe(true)
} finally {
dispose()
}
})
})
@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
activeDirectories.delete(key)
activationToggles.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => input.queryOptions.path(key))
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const referenceQuery = useQuery(() => input.queryOptions.references(key))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({
project: "",
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return !providerQuery.isLoading
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -236,7 +247,7 @@ export function createChildStoreManager(input: {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
},
get lsp_ready() {
return !lspQuery.isLoading
return instanceQueriesEnabled() && !lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -250,6 +261,7 @@ export function createChildStoreManager(input: {
children[key] = child
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
@@ -290,6 +302,7 @@ export function createChildStoreManager(input: {
pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@@ -301,6 +314,7 @@ export function createChildStoreManager(input: {
const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@@ -314,6 +328,16 @@ export function createChildStoreManager(input: {
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
}
// Passive Home/project metadata reads must not initialize the directory.
// A real directory access enables these queries once for the store lifetime.
// TODO(v2): After Home switches to v2.project.list and root-filtered,
// updated-time v2.session.list, remove any Home-only passive child creation.
function activate(key: DirectoryKey) {
if (activeDirectories.has(key)) return
activeDirectories.add(key)
activationToggles.get(key)?.(true)
}
function disableMcp(directory: string) {
const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return
@@ -360,6 +384,7 @@ export function createChildStoreManager(input: {
unpin,
pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp,
disposeDirectory,
runEviction,
@@ -243,6 +243,22 @@ describe("applyDirectoryEvent", () => {
expect(store.session_status.ses_1).toBeUndefined()
})
test("ignores an archived session absent from a passive directory store", () => {
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session).toEqual([])
expect(store.sessionTotal).toBe(0)
})
test("cleans session caches when deleted and decrements only root totals", () => {
const cases = [
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
@@ -146,15 +146,14 @@ export function applyDirectoryEvent(input: {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (!result.found) break
if (input.store.session[result.index]!.time.archived === info.time.archived) break
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
homeSessionIndexSessions,
homeSessionIndexRefresh,
parseHomeSessionIndex,
retainHomeSessions,
} from "./home-session-index"
const session = (input: {
id: string
directory?: string
parentID?: string
archived?: number
updated?: number
}) => ({
id: input.id,
parentID: input.parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
title: input.id,
location: { directory: input.directory ?? "/project" },
})
describe("Home V2 session index", () => {
test("loads the Home index with one global V2 request", async () => {
const calls: unknown[] = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
return { data: { data: [session({ id: "root" })], cursor: {} } }
})
expect(result.sessions).toHaveLength(1)
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
})
test("loads subsequent pages until the session index is complete", async () => {
const calls: unknown[] = []
const controller = new AbortController()
const result = await loadHomeSessionIndex(
async (input, options) => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" },
},
}
}
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
},
0,
controller.signal,
)
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls).toEqual([
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
{
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
signal: controller.signal,
},
])
})
test("maps visible roots to Home session summaries", () => {
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
session({ id: "child", parentID: "root", updated: 40 }),
session({ id: "archived", archived: 50, updated: 50 }),
])
expect(result).toEqual([
expect.objectContaining({
id: "root",
slug: "root",
version: "",
directory: "/project",
projectID: "project",
title: "root",
time: { created: 1, updated: 30 },
}),
])
})
test("preserves the per-directory Home retention limit", () => {
const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
directory: index % 2 === 0 ? "/one" : "/two",
}))
const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
})
test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created",
properties: { sessionID: created.id, info: created },
})
expect(
applyHomeSessionEvent(afterCreate, {
type: "session.deleted",
properties: { sessionID: initial[0]!.id, info: initial[0]! },
}),
).toEqual([created])
})
test("applies only events newer than the index baseline", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const stale = { ...initial[0], title: "stale" }
const current = { ...initial[0], title: "current" }
const first = appendHomeSessionEvent(undefined, {
type: "session.updated",
properties: { sessionID: stale.id, info: stale },
})
const events = appendHomeSessionEvent(first, {
type: "session.updated",
properties: { sessionID: current.id, info: current },
})
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
})
test("refetches after reconnect, disposal, and session moves", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
})
})
@@ -0,0 +1,174 @@
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info: Session }
}
export type HomeSessionEvents = {
sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }>
}
export type HomeSessionIndex = {
sessions: Session[]
eventSequence: number
}
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse }
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<HomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
const data: SessionV2Info[] = []
let cursor: string | undefined
for (;;) {
const response = await list(
{
limit: HOME_V2_SESSION_PAGE_LIMIT,
order: "desc",
...(cursor ? { cursor } : {}),
},
{ signal },
)
const page = response.data!
data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence }
cursor = page.cursor.next
}
}
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
const sequence = (current?.sequence ?? 0) + 1
return {
sequence,
entries: [...(current?.entries ?? []), { sequence, event }],
}
}
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
return {
sequence: current?.sequence ?? sequence,
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
}
}
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
if (!index) return []
return (events?.entries ?? [])
.filter((entry) => entry.sequence > index.eventSequence)
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
}
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected }
return {
connected,
refetch: event === "global.disposed" || event === "session.next.moved",
}
}
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
const indexKey = homeSessionIndexKey(server)
const eventsKey = homeSessionEventsKey(server)
let connected = false
return {
indexKey,
eventsKey,
eventSequence() {
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
},
complete(sequence: number) {
// Keep events received after the fetch began so its response cannot overwrite them.
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
},
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
return homeSessionIndexSessions(index, events)
},
apply(event: HomeSessionEvent) {
if (!queryClient.getQueryState(indexKey)) return
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
queryClient.setQueryData(eventsKey, next)
return
}
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
if (index) {
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
sessions: homeSessionIndexSessions(index, next),
eventSequence: next.sequence,
})
}
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
},
refresh(event: Event["type"]) {
const result = homeSessionIndexRefresh(event, connected)
connected = result.connected
if (!result.refetch) return
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
},
}
}
// TODO(v2): This deliberately dumb full-table scan is necessary because the
// current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({
// 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 []
return [toLegacySummary(item)]
})
}
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
}
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 (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
if (index === -1) return [...sessions, info]
return sessions.with(index, info)
}
function toLegacySummary(session: SessionV2Info): Session {
return {
id: session.id,
slug: session.id,
projectID: session.projectID,
workspaceID: session.location.workspaceID,
directory: session.location.directory,
path: session.subpath,
parentID: session.parentID,
cost: session.cost,
tokens: session.tokens,
title: session.title,
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}
+7
View File
@@ -3,6 +3,7 @@ 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"
@@ -142,6 +143,9 @@ 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
@@ -187,6 +191,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})
},
})
setConnection("status", "connected")
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
@@ -218,6 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
if (abort.signal.aborted || !started || generation !== active) return
setConnection("status", "reconnecting")
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
@@ -269,6 +275,7 @@ 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,
+13 -1
View File
@@ -43,6 +43,7 @@ import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
@@ -153,6 +154,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
})
const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
let bootedAt = 0
let bootingRoot = false
@@ -376,6 +378,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const recent = bootingRoot || Date.now() - bootedAt < 1500
session.apply(event)
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event)
}
homeSessions.refresh(event.type)
if (directory === "global") {
applyGlobalEvent({
@@ -390,6 +396,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
queue.push(directory)
}
}
@@ -405,15 +412,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
directory,
store,
setStore,
push: queue.push,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
@@ -486,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
session,
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
+12
View File
@@ -621,6 +621,14 @@ 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",
@@ -722,6 +730,8 @@ 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":
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
@@ -929,6 +939,8 @@ 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": "كلمة المرور",
+12
View File
@@ -627,6 +627,14 @@ 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",
@@ -733,6 +741,8 @@ 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",
@@ -943,6 +953,8 @@ 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",
+12
View File
@@ -685,6 +685,14 @@ 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",
@@ -797,6 +805,8 @@ 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",
@@ -1019,6 +1029,8 @@ 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",
+12
View File
@@ -680,6 +680,14 @@ 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",
@@ -791,6 +799,8 @@ 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",
@@ -1011,6 +1021,8 @@ 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",
+12
View File
@@ -636,6 +636,14 @@ 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",
@@ -745,6 +753,8 @@ 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",
@@ -955,6 +965,8 @@ 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",
+12
View File
@@ -332,6 +332,8 @@ 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",
@@ -728,6 +730,14 @@ 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",
@@ -886,6 +896,8 @@ 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",
+12
View File
@@ -686,6 +686,14 @@ 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",
@@ -802,6 +810,8 @@ 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",
@@ -1027,6 +1037,8 @@ 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",
+13
View File
@@ -632,6 +632,14 @@ 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 à louverture 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": "Sauthentifier à nouveau",
"status.popover.ariaLabel": "Configurations des serveurs",
"status.popover.tab.servers": "Serveurs",
"status.popover.tab.mcp": "MCP",
@@ -742,6 +750,9 @@ 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",
@@ -954,6 +965,8 @@ 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",
+12
View File
@@ -623,6 +623,14 @@ 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",
@@ -728,6 +736,8 @@ 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":
"モバイルではタイトルバーとセッションタブを画面下部に配置します",
@@ -936,6 +946,8 @@ 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": "パスワード",
+12
View File
@@ -503,6 +503,14 @@ 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",
@@ -782,6 +790,8 @@ 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": "비밀번호",
@@ -995,6 +1005,8 @@ 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": "사용자 지정 에이전트",
+12
View File
@@ -567,6 +567,14 @@ 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",
@@ -869,6 +877,8 @@ 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",
@@ -1090,6 +1100,8 @@ 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",
+12
View File
@@ -627,6 +627,14 @@ 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",
@@ -733,6 +741,8 @@ 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",
@@ -942,6 +952,8 @@ 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",
+12
View File
@@ -683,6 +683,14 @@ 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",
@@ -798,6 +806,8 @@ 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":
"На мобильных устройствах размещать строку заголовка и вкладки сессии внизу экрана",
@@ -1022,6 +1032,8 @@ 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": "пароль",
+12
View File
@@ -679,6 +679,14 @@ 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",
@@ -789,6 +797,8 @@ 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":
"วางแถบชื่อเรื่องและแท็บเซสชันไว้ด้านล่างของหน้าจอบนอุปกรณ์เคลื่อนที่",
@@ -1006,6 +1016,8 @@ 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": "รหัสผ่าน",
+12
View File
@@ -689,6 +689,14 @@ 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",
@@ -803,6 +811,8 @@ 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",
@@ -1026,6 +1036,8 @@ 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",
+12
View File
@@ -333,6 +333,8 @@ 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": "Сервери",
@@ -729,6 +731,14 @@ 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",
@@ -889,6 +899,8 @@ 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":
"Розмістити заголовок і вкладки сесії внизу екрана на мобільних пристроях",
+12
View File
@@ -675,6 +675,14 @@ 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",
@@ -786,6 +794,8 @@ 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": "自定义智能体",
@@ -999,6 +1009,8 @@ 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": "密码",
+12
View File
@@ -673,6 +673,14 @@ 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",
@@ -781,6 +789,8 @@ 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": "自訂代理程式",
@@ -995,6 +1005,8 @@ 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": "密碼",
+58 -23
View File
@@ -40,7 +40,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import {
@@ -50,7 +50,6 @@ import {
getProjectAvatarSource,
homeProjectDirectories,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
@@ -69,6 +68,11 @@ import { archiveHomeSession } from "./home-session-archive"
import { shouldOpenSessionInBackground } from "./home-session-open"
import { showToast } from "@/utils/toast"
import { fileManagerApp } from "@/utils/file-manager"
import {
loadHomeSessionIndex,
retainHomeSessions,
type HomeSessionEvents,
} from "@/context/global-sync/home-session-index"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
@@ -106,22 +110,24 @@ const HOME_SEARCH_RESULT_META =
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
function buildHomeSessionRecords(input: {
sync: Pick<ServerSync, "child">
sessions: () => Session[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
return [
...new Map(
input
.projectDirectories()
.flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now()))
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const project = projectForSession(session, input.projects(), input.projectByID())
const directory = pathKey(session.directory)
const project =
input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return {
session,
@@ -286,6 +292,7 @@ export function NewHome() {
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const homeSessions = () => focusedSync().homeSessions
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
@@ -318,24 +325,47 @@ export function NewHome() {
}
return language.t("home.sessions.search.placeholder")
})
const sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}))
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(
projectDirectories().map((directory) =>
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
),
queryKey: homeSessions().indexKey,
enabled: !!focusedServerCtx(),
queryFn: async ({ signal }) => {
const ctx = focusedServerCtx()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
eventSequence,
signal,
)
return null
cache.complete(eventSequence)
return index
},
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}))
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sync: focusedSync(),
sessions: indexedSessions,
projectDirectories,
projects,
projectByID,
@@ -363,8 +393,7 @@ export function NewHome() {
prefetched.add(key)
createRoot((dispose) => {
try {
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
void directory.session
void ctx.sync.session
.sync(record.session.id)
.then(() => {
return Promise.all(
@@ -484,7 +513,13 @@ export function NewHome() {
}
function openSession(session: Session, options?: OpenSessionOptions) {
const project = projectForSession(session, projects(), projectByID())
const directoryKey = pathKey(session.directory)
const project =
projects().find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, projects(), projectByID())
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
-16
View File
@@ -1,13 +1,10 @@
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 { 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,
@@ -25,7 +22,6 @@ 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"
@@ -86,7 +82,6 @@ export default function NewSessionPage() {
])
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const newSessionWorktree = createMemo(() => {
@@ -126,17 +121,6 @@ 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]">
+6 -1
View File
@@ -58,6 +58,8 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import {
createPromptInputController,
@@ -1054,7 +1056,10 @@ export default function Page() {
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return
inputRef?.focus()
const input = inputRef
if (!input) return
input.focus()
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
}
}
+44 -17
View File
@@ -1,8 +1,32 @@
import { expect, test } from "bun:test"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
const writes: { offset: number; adjustments?: number }[] = []
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 5,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 200 },
anchorTo: "end",
scrollEndThreshold: 1,
getScrollElement: () => null,
scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }),
observeElementRect: () => {},
observeElementOffset: () => {},
})
virtualizer.getTotalSize()
virtualizer.resizeItem(4, 120)
expect(writes).toEqual([{ offset: 50, adjustments: 70 }])
writes.length = 0
virtualizer.resizeItem(4, 200)
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
@@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
})
})
test("logical scroll offset includes pending measurement adjustments", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: 2,
getScrollElement: () => null,
estimateSize: () => 60,
initialOffset: 100,
initialRect: { width: 800, height: 60 },
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 100)
expect(virtualizer.scrollOffset).toBe(100)
expect(virtualizer.getLogicalScrollOffset()).toBe(140)
dispose()
test("clamps oversized offsets with scroll margin and padding changes", () => {
const options = (paddingEnd: number) => ({
count: 20,
estimateSize: () => 60,
initialOffset: Number.MAX_SAFE_INTEGER,
initialRect: { width: 800, height: 600 },
scrollMargin: 64,
paddingEnd,
overscan: 1,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
virtualizer.setOptions(options(600))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
})
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.17.19",
"version": "1.17.20",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.19",
"version": "1.17.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.19",
"version": "1.17.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.20",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.20",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.20",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.17.19",
"version": "1.17.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.20",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.20",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.20",
"name": "opencode",
"type": "module",
"license": "MIT",
+1 -64
View File
@@ -6,15 +6,12 @@ import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { isRecord } from "@/util/record"
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const CODEX_COMPATIBILITY_VERSION = "0.144.0"
const RESPONSES_LITE_MODEL = "gpt-5.6-luna"
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
@@ -421,9 +418,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
const requestInit = {
...init,
body: parsed.pathname.endsWith("/responses")
? prepareResponsesLiteRequest(init?.body, headers)
: init?.body,
body: init?.body,
headers,
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
@@ -568,61 +563,3 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
},
}
}
function prepareResponsesLiteRequest(body: BodyInit | null | undefined, headers: Headers) {
if (typeof body !== "string") return body
const request: unknown = JSON.parse(body)
if (!isRecord(request)) return body
if (request.model !== RESPONSES_LITE_MODEL) return body
if (!Array.isArray(request.input)) throw new Error("Responses Lite requires an input array")
if (request.tools !== undefined && !Array.isArray(request.tools)) {
throw new Error("Responses Lite requires a tools array")
}
if (request.instructions !== undefined && typeof request.instructions !== "string") {
throw new Error("Responses Lite requires string instructions")
}
const sessionID = headers.get("session-id")
if (!sessionID) throw new Error("Responses Lite requires a session-id header")
function stripImageDetail(value: unknown) {
if (Array.isArray(value)) {
value.forEach(stripImageDetail)
return
}
if (!isRecord(value)) return
if (value.type === "input_image") delete value.detail
Object.values(value).forEach(stripImageDetail)
}
stripImageDetail(request.input)
request.input = [
{ type: "additional_tools", role: "developer", tools: request.tools ?? [] },
...(request.instructions
? [
{
type: "message",
role: "developer",
content: [{ type: "input_text", text: request.instructions }],
},
]
: []),
...request.input,
]
delete request.tools
delete request.instructions
request.tool_choice = "auto"
request.parallel_tool_calls = false
request.prompt_cache_key = sessionID
request.reasoning = {
...(isRecord(request.reasoning) ? request.reasoning : {}),
context: "all_turns",
}
headers.set("session-id", sessionID)
headers.set("x-session-affinity", sessionID)
headers.set("version", CODEX_COMPATIBILITY_VERSION)
headers.set(OpenAIWebSocketPool.RESPONSES_LITE_HEADER, "true")
headers.delete("content-length")
return JSON.stringify(request)
}
+1 -12
View File
@@ -4,8 +4,6 @@ import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"
export const TITLE_HEADER = "x-opencode-title"
export const RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite"
const RESPONSES_LITE_CLIENT_METADATA = "ws_request_header_x_openai_internal_codex_responses_lite"
export interface CreateWebSocketFetchOptions {
httpFetch?: typeof globalThis.fetch
@@ -100,16 +98,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
})
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket: entry.socket,
body:
internalHeaders[RESPONSES_LITE_HEADER] === "true"
? {
...body,
client_metadata: {
...(isRecord(body.client_metadata) ? body.client_metadata : {}),
[RESPONSES_LITE_CLIENT_METADATA]: "true",
},
}
: body,
body,
idleTimeout,
signal: init?.signal ?? undefined,
onFirstEvent: (error) => resolveFirstEvent(error ?? true),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
@@ -0,0 +1,6 @@
@font-face {
font-family: "Inter";
src: url("@opencode-ai/ui/fonts/Inter.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
}
@@ -1,6 +1,7 @@
import "@opencode-ai/ui/styles/tailwind"
import "@opencode-ai/session-ui/styles"
import "@opencode-ai/ui/v2/styles/tailwind.css"
import "./preview.css"
import { createEffect, onCleanup, onMount } from "solid-js"
import addonA11y from "@storybook/addon-a11y"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.17.19",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+3
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSubtleSyntax,
generateSyntax,
generateSystem,
hasTheme,
@@ -268,6 +269,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => renderer.setBackgroundColor(values().background))
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
return {
theme: new Proxy(values(), {
@@ -282,6 +284,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
all: allThemes,
has: hasTheme,
syntax,
subtleSyntax,
mode: () => store.mode,
locked: () => store.lock !== undefined,
lock: () => pin(store.mode),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.17.19",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"repository": {
+6
View File
@@ -96,3 +96,9 @@
transform: scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="popover-content"] {
animation: none;
}
}
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test"
import { Marked } from "marked"
import { markedCodeSpanBoundary } from "./marked-code-span"
test("preserves code spans adjacent to tildes", async () => {
const marked = new Marked(markedCodeSpanBoundary)
expect(await marked.parse("~`0.1576` to measurement-window-only `0.00092`")).toBe(
"<p>~<code>0.1576</code> to measurement-window-only <code>0.00092</code></p>\n",
)
expect(await marked.parse("`before`~`after`")).toBe("<p><code>before</code>~<code>after</code></p>\n")
expect(await marked.parse("~~`deleted code`~~")).toBe("<p><del><code>deleted code</code></del></p>\n")
})
@@ -0,0 +1,17 @@
import type { MarkedExtension } from "marked"
// Keep adjacent tilde and backtick runs separate until markedjs/marked#4011 is released.
export const markedCodeSpanBoundary = {
tokenizer: {
inlineText(src) {
const match = /^(`+(?=~)|~+(?=`))/.exec(src)
if (!match) return false
return {
type: "text",
raw: match[0],
text: match[0],
escaped: this.lexer.state.inRawBlock,
}
},
},
} satisfies MarkedExtension
+2
View File
@@ -3,6 +3,7 @@ import markedShiki from "marked-shiki"
import katex from "katex"
import { bundledLanguages, type BundledLanguage } from "shiki"
import { createSimpleContext } from "./helper"
import { markedCodeSpanBoundary } from "./marked-code-span"
import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
export const OpenCodeTheme = {
@@ -521,6 +522,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
name: "Marked",
init: (props: { nativeParser?: NativeMarkdownParser }) => {
const jsParser = marked.use(
markedCodeSpanBoundary,
{
renderer: {
link({ href, title, text }) {
+8
View File
@@ -33,6 +33,14 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
},
tools: {
viewBox: "0 0 16 16",
body: `<path d="M8 6.74934V9.24934M10.668 6.74934V9.24934M5.33398 6.74934V9.24934M1.33398 2.44418V13.5542H14.6673V2.44418H1.33398Z" stroke="currentColor" stroke-linecap="square"/>`,
},
"tools-warning": {
viewBox: "0 0 16 16",
body: `<path d="M9.50073 2.44498H1.33398V13.555H14.6673V6.49997M8.00073 6.74934V9.24934M10.6687 6.74934V9.24934M5.33472 6.74934V9.24934" stroke="currentColor" stroke-linecap="square"/>`,
},
"status-active": {
viewBox: "0 0 20 20",
body: `<path d="M18 2H2V10H18V2Z" fill="currentColor" fill-opacity="0.1"/><path d="M2 18H18V10H2V18Z" fill="currentColor" fill-opacity="0.1"/><path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
@@ -37,7 +37,7 @@
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 100%;
line-height: 16px;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.17.19",
"version": "1.17.20",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -1,45 +0,0 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index 7e97823ea769398ccd9cf449b178c77675ed252c..d75183f11421af0e20e4e8a996af99c300ad936d 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -39,7 +39,9 @@ function createVirtualizerBase(options) {
(_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync);
}
}));
- virtualizer.measure();
+ virtualizer._willUpdate();
+ setVirtualItems(store.reconcile(instance.getVirtualItems(), { key: "index" }));
+ setTotalSize(instance.getTotalSize());
});
return virtualizer;
}
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 1d525463775fef3e8ece6ab191061ef9d0a36d73..14c680a2088c49a33959d8118cf32ee599ab83c2 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -38,7 +38,9 @@ function createVirtualizerBase(options) {
(_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync);
}
}));
- virtualizer.measure();
+ virtualizer._willUpdate();
+ setVirtualItems(reconcile(instance.getVirtualItems(), { key: "index" }));
+ setTotalSize(instance.getTotalSize());
});
return virtualizer;
}
diff --git a/src/index.tsx b/src/index.tsx
index 69ac34fd70753b9bd00683c2540be7f62630f8f2..9f16672aa0f4a044aa2b35754d385d7d8031f743 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -81,7 +81,9 @@ function createVirtualizerBase<
},
}),
)
- virtualizer.measure()
+ virtualizer._willUpdate()
+ setVirtualItems(reconcile(instance.getVirtualItems(), { key: 'index' }))
+ setTotalSize(instance.getTotalSize())
})
return virtualizer
@@ -1,105 +0,0 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index df75d0cf0347b62906e04e454d4f4ef062ed5c48..e5110715b049c6c8b992bd1375a7432df7c0182e 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -526,6 +526,7 @@ class Virtualizer {
this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset);
return this.scrollOffset;
};
+ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments;
this.getFurthestMeasurement = (measurements, index) => {
const furthestMeasurementsFound = /* @__PURE__ */ new Map();
const furthestMeasurements = /* @__PURE__ */ new Map();
@@ -715,10 +716,12 @@ class Virtualizer {
this.options.lanes
],
(measurements, outerSize, scrollOffset, lanes) => {
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
return this.range = measurements.length > 0 && outerSize > 0 ? calculateRange({
measurements,
outerSize,
- scrollOffset,
+ scrollOffset: effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can
// read start/end directly from Float64Array, skipping the
diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts
index c61ee17752565253f795c7fc7d57e86237ecbb52..705bb7e3a121b040fb1a3e7890179eaa3e9b219e 100644
--- a/dist/cjs/index.d.cts
+++ b/dist/cjs/index.d.cts
@@ -108,6 +108,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
scrollRect: Rect | null;
scrollOffset: number | null;
scrollDirection: ScrollDirection | null;
+ getLogicalScrollOffset: () => number;
private scrollAdjustments;
private _iosDeferredAdjustment;
private _iosTouching;
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index b03abab604eb6578f6f56ff92c489259cfaf8f19..0495f372ea000dffc416c4f56809946f7ba73099 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -108,6 +108,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
scrollRect: Rect | null;
scrollOffset: number | null;
scrollDirection: ScrollDirection | null;
+ getLogicalScrollOffset: () => number;
private scrollAdjustments;
private _iosDeferredAdjustment;
private _iosTouching;
diff --git a/dist/esm/index.js b/dist/esm/index.js
index e384cf7541978a2782b9dca68146e869b16ac3f2..77af22006325377bf9ca0052a3554f881f9e75fe 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -524,6 +524,7 @@ class Virtualizer {
this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset);
return this.scrollOffset;
};
+ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments;
this.getFurthestMeasurement = (measurements, index) => {
const furthestMeasurementsFound = /* @__PURE__ */ new Map();
const furthestMeasurements = /* @__PURE__ */ new Map();
@@ -713,10 +714,12 @@ class Virtualizer {
this.options.lanes
],
(measurements, outerSize, scrollOffset, lanes) => {
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
return this.range = measurements.length > 0 && outerSize > 0 ? calculateRange({
measurements,
outerSize,
- scrollOffset,
+ scrollOffset: effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can
// read start/end directly from Float64Array, skipping the
diff --git a/src/index.ts b/src/index.ts
index d35b3e0695a9c85b261bc1a4fbe23c0a60d5b204..c504e630a60a3a7791345622bb02754b1308fd86 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1047,6 +1047,8 @@ export class Virtualizer<
return this.scrollOffset
}
+ getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments
+
private getFurthestMeasurement = (
measurements: Array<VirtualItem>,
index: number,
@@ -1334,12 +1336,14 @@ export class Virtualizer<
this.options.lanes,
],
(measurements, outerSize, scrollOffset, lanes) => {
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0)
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset)
return (this.range =
measurements.length > 0 && outerSize > 0
? calculateRange({
measurements,
outerSize,
- scrollOffset,
+ scrollOffset: effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can
// read start/end directly from Float64Array, skipping the
@@ -0,0 +1,108 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6c508630d 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -716,17 +716,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
- this.options.lanes
+ this.options.lanes,
+ this.options.paddingEnd
],
- (measurements, outerSize, scrollOffset, lanes) => {
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null;
return null;
}
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
this.range = calculateRangeImpl(
measurements,
outerSize,
- scrollOffset,
+ effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts
index c61ee17752565253f795c7fc7d57e86237ecbb52..430c622a18c69407df9f1ca82ee9440e9be287b7 100644
--- a/dist/cjs/index.d.cts
+++ b/dist/cjs/index.d.cts
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
startIndex: number;
endIndex: number;
} | null;
- updateDeps(newDeps: [VirtualItem[], number, number, number]): void;
+ updateDeps(newDeps: [VirtualItem[], number, number, number, number]): void;
};
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index b03abab604eb6578f6f56ff92c489259cfaf8f19..940310f9215ba5bf666c5b71e92dddbbbc85635d 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
startIndex: number;
endIndex: number;
} | null;
- updateDeps(newDeps: [VirtualItem[], number, number, number]): void;
+ updateDeps(newDeps: [VirtualItem[], number, number, number, number]): void;
};
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 3032c0ca457582be3f47923cba1f7d92c848745c..6e52124048fb3bc7b7ee37e763354b33593a6285 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -714,17 +714,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
- this.options.lanes
+ this.options.lanes,
+ this.options.paddingEnd
],
- (measurements, outerSize, scrollOffset, lanes) => {
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null;
return null;
}
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
this.range = calculateRangeImpl(
measurements,
outerSize,
- scrollOffset,
+ effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/src/index.ts b/src/index.ts
index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9a5f694e2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1367,16 +1367,19 @@ export class Virtualizer<
this.getSize(),
this.getScrollOffset(),
this.options.lanes,
+ this.options.paddingEnd,
],
- (measurements, outerSize, scrollOffset, lanes) => {
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null
return null
}
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0)
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset)
this.range = calculateRangeImpl(
measurements,
outerSize,
- scrollOffset,
+ effectiveScrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.17.19",
"version": "1.17.20",
"publisher": "sst-dev",
"repository": {
"type": "git",