chore: merge dev into v2

This commit is contained in:
Aiden Cline 2026-07-14 20:01:41 +00:00
commit 6911456c2f
122 changed files with 3190 additions and 754 deletions

View file

@ -18,4 +18,4 @@ simonklee
Slickstef11 Slickstef11
usrnk1 usrnk1
vimtor vimtor
starptech StarpTech

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=", "x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
"aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=", "aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
"aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=", "aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
"x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw=" "x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
} }
} }

View file

@ -136,6 +136,9 @@ export async function setupTimeline(
}, },
}), }),
) )
if (settings.newLayoutDesigns === false) {
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
}
}, input.settings ?? {}) }, input.settings ?? {})
if (input.locale) { if (input.locale) {
await page.addInitScript((locale) => { await page.addInitScript((locale) => {

View file

@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
await page.addInitScript( await page.addInitScript(
({ directory, draftID, server }) => { ({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem( localStorage.setItem(
"opencode.window.browser.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]), JSON.stringify([{ type: "draft", draftID, server, directory }]),

View file

@ -0,0 +1,270 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
const sessionA = session("ses_server_a", directoryA, "Server A session")
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
await mockServers(page, permissionRequests)
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
await expect(input).toBeEnabled()
permissionRequests.length = 0
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(input).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === directoryB
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
})
test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
})
await mockServers(page, permissionRequests, permissionResponses)
await configureServers(page, [
{ type: "session", server: serverA, sessionId: sessionA.id },
{ type: "session", server: serverB, sessionId: sessionB.id },
])
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("directory") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await transport.waitForConnection()
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a",
type: "permission.asked",
properties: {
id: "permission-background-a",
sessionID: sessionA.id,
permission: "bash",
patterns: ["git status"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
])
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a-child",
type: "permission.asked",
properties: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
permission: "bash",
patterns: ["git diff"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
{
origin: serverA,
directory: directoryA,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { response: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
sessionID: string
permissionID: string
body: unknown
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
},
{ serverB, tabs },
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("directory")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (route.request().method() === "POST" && response) {
permissionResponses.push({
origin: url.origin,
directory: requestDirectory ?? undefined,
sessionID: response[1]!,
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, sessions)
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (url.pathname === "/permission") {
permissionRequests.push(url.toString())
return json(route, [])
}
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: directory,
config: directory,
worktree: directory,
directory,
home: directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
return json(route, {})
})
}
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
function provider(id: string) {
const name = id === "server-b" ? "Server B" : "Server A"
return {
all: [
{
id,
name: `${name} Provider`,
models: {
[id]: {
id,
name: `${name} Model`,
family: id,
release_date: "2026-01-01",
limit: { context: 200_000 },
},
},
},
],
connected: [id],
default: { providerID: id, modelID: id },
}
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}

View file

@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const assistants = Array.from({ length: 14 }, (_, index) => const initialPageSize = 20
const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID, parentID: userID,
created: 1700000001000 + index * 1_000, created: 1700000001000 + index * 1_000,
completed: index < 13, completed: index < initialPageSize,
}), }),
) )
const messages = [userMessage(), ...assistants] const messages = [userMessage(), ...assistants]
@ -46,7 +48,7 @@ const scenarios = [
test.use({ viewport: { width: 646, height: 1385 } }) test.use({ viewport: { width: 646, height: 1385 } })
for (const scenario of scenarios) { for (const scenario of scenarios) {
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => { test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
const requests: { before?: string; phase: "start" | "end" }[] = [] const requests: { before?: string; phase: "start" | "end" }[] = []
const pages: { before?: string; limit: number }[] = [] const pages: { before?: string; limit: number }[] = []
const roots: { sessionID: string; messageID: string }[] = [] const roots: { sessionID: string; messageID: string }[] = []
@ -101,36 +103,51 @@ for (const scenario of scenarios) {
} }
}, },
}) })
await page.addInitScript( await page.addInitScript(() => {
({ userPartID, lastPartID }) => { const visibleParts = () => {
const state = { armed: false, hidden: false, samples: 0, stop: false } const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const sample = () => { const view = viewport?.getBoundingClientRect()
if (state.armed) { if (!viewport || !view) return []
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]") return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport") .filter((part) => {
const view = viewport?.getBoundingClientRect() const rect = part.getBoundingClientRect()
const visible = (partID: string) => { return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`) })
const rect = part?.getBoundingClientRect() .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
return ( }
!!rect && const state = {
!!view && armed: false,
rect.width > 0 && hidden: false,
rect.height > 0 && visibleParts: [] as string[],
rect.bottom > view.top && samples: 0,
rect.top < view.bottom stop: false,
) arm() {
} state.visibleParts = visibleParts()
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true state.armed = true
state.samples++ },
}
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const sample = () => {
if (state.armed) {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
const rect = part?.getBoundingClientRect()
return (
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
)
} }
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
state.hidden = true
state.samples++
} }
requestAnimationFrame(() => setTimeout(sample, 0)) if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
}, }
{ userPartID, lastPartID }, requestAnimationFrame(() => setTimeout(sample, 0))
) })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection() await transport.waitForConnection()
@ -143,23 +160,28 @@ for (const scenario of scenarios) {
"messages:start:latest", "messages:start:latest",
"messages:end:latest", "messages:end:latest",
`message:${userID}`, `message:${userID}`,
`messages:start:${messages.at(-2)!.info.id}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`,
]) ])
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
await page.evaluate(() => { await page.evaluate(() => {
;( ;(
window as Window & { window as Window & {
__historyRootProbe?: { armed: boolean } __historyRootProbe?: { arm(): void }
} }
).__historyRootProbe!.armed = true ).__historyRootProbe!.arm()
}) })
await waitForProbeSamples(page, 0) await waitForProbeSamples(page, 0)
expect(await historyRootHidden(page)).toBe(false) expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page) const beforeHistory = await probeSamples(page)
history.resolve() history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14) await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory) await waitForProbeSamples(page, beforeHistory)
expect(pages[0]).toEqual({ before: undefined, limit: 2 }) expect(pages).toEqual([
{ before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
])
expect(roots).toEqual([{ sessionID, messageID: userID }]) expect(roots).toEqual([{ sessionID, messageID: userID }])
const message = messageUpdated(scenario.info) const message = messageUpdated(scenario.info)
@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) {
) )
} }
function historyRootHidden(page: Page) { function visibleContentHidden(page: Page) {
return page.evaluate( return page.evaluate(
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
) )

View file

@ -1,5 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
const projectID = "proj_terminal_composer_focus" const projectID = "proj_terminal_composer_focus"
const sessionID = "ses_terminal_composer_focus" const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus" const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
test.use({ viewport: { width: 1440, height: 900 } }) test.use({ viewport: { width: 1440, height: 900 } })
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: { project: {
@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
}) })
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus") await expectSessionTitle(page, "Terminal composer focus")
@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async
await expect(composer).toBeFocused() await expect(composer).toBeFocused()
await expect(composer).toHaveText("a") await expect(composer).toHaveText("a")
}) })
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const created = { count: 0 }
await page.route("**/pty", (route) => {
created.count += 1
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
})
})
await page.route(/ghostty-web/, async (route) => {
ghostty.resolve()
await release.promise
await route.continue()
})
await seedCachedTerminal(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
expect(created.count).toBe(0)
await ghostty.promise
await composer.click()
await expect(composer).toBeFocused()
release.resolve()
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.waitForTimeout(300)
await expect(composer).toBeFocused()
})
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(/ghostty-web/, async (route) => {
ghostty.resolve()
await release.promise
await route.continue()
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await ghostty.promise
await composer.click()
await expect(composer).toBeFocused()
release.resolve()
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.waitForTimeout(50)
await expect(composer).toBeFocused()
})
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 }
await page.route("**/pty", (route) => {
created.count += 1
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(next),
})
})
await page.route(`**/pty/${newPtyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await composer.click()
await expect(composer).toBeFocused()
await page.getByRole("button", { name: "New terminal" }).click()
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
localStorage.setItem(
terminalKey,
JSON.stringify({
active: ptyID,
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
}),
)
},
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
)
}

View file

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Timeline Suspense Reproduction</title>
<style>
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
background: #171717;
color: #f5f5f5;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
#root {
padding: 24px;
}
</style>
</head>
<body>
<main id="root"></main>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,315 @@
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
import { render } from "solid-js/web"
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
const rowCount = 2_000
const rowHeight = 40
const parameters = new URLSearchParams(location.search)
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
type MutationEvent = {
kind: "removed" | "added"
callbackTime: number
callbackFrame: number
routeConnectedInCallback: boolean
nativeOffsetInCallback: number
}
type Snapshot = {
mode: {
resource: "baseline" | "guard"
reconnect: "baseline" | "candidate"
}
operation: {
sequence: number
phase: string
time: number
frame: number
}
resourceState: string
routeConnected: boolean
viewportConnected: boolean
viewportOwnedByRoute: boolean
sameRoute: boolean
sameViewport: boolean
sameSurface: boolean
sameMountedRows: boolean
nativeOffset: number
coreOffset: number
rangeStart: number
rangeEnd: number
indexes: number[]
domIndexes: number[]
logicalSurfaceHeight: number
renderedSurfaceHeight: number
viewportClientHeight: number
viewportScrollHeight: number
visibleRows: number
minimumRowTop: number
domScrollEvents: number
lastScrollTrusted: boolean
coreOffsetCallbackCalls: number
offsetCallbackSources: "observer"[]
rectObserverCallbacks: number
ignoredDetachedZeroRects: number
syntheticScrollDispatches: number
mutationEvents: MutationEvent[]
}
declare global {
interface Window {
timelineSuspense: {
prepare: () => Promise<Snapshot>
trigger: () => void
resolve: () => void
frames: (count?: number) => Promise<void>
snapshot: () => Snapshot
}
}
}
function App() {
const [refresh, setRefresh] = createSignal(false)
let resolveResource: (() => void) | undefined
const [resource] = createResource(
refresh,
(version) =>
new Promise<string>((resolve) => {
resolveResource = () => resolve(`settled-${version}`)
}),
{ initialValue: "settled" },
)
function Route() {
let route: HTMLElement | undefined
let viewport: HTMLDivElement | undefined
let surface: HTMLDivElement | undefined
let initialRoute: HTMLElement | undefined
let initialViewport: HTMLDivElement | undefined
let initialSurface: HTMLDivElement | undefined
let initialRows: HTMLElement[] = []
let phase = "mounting"
let browserFrame = 0
let snapshotSequence = 0
let domScrollEvents = 0
let lastScrollTrusted = false
let coreOffsetCallbackCalls = 0
let rectObserverCallbacks = 0
let ignoredDetachedZeroRects = 0
const offsetCallbackSources: "observer"[] = []
const mutationEvents: MutationEvent[] = []
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rowCount,
getScrollElement: () => viewport ?? null,
estimateSize: () => rowHeight,
initialRect: { width: 900, height: 600 },
overscan: 2,
observeElementRect: (instance, callback) =>
observeElementRect(instance, (rect) => {
rectObserverCallbacks++
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
if (!instance.scrollElement?.isConnected && rect.height === 0) {
ignoredDetachedZeroRects++
return
}
callback(rect)
}),
observeElementOffset: (instance, callback) => {
const deliver = (offset: number, isScrolling: boolean) => {
coreOffsetCallbackCalls++
offsetCallbackSources.push("observer")
callback(offset, isScrolling)
}
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
return observeElementOffset(instance, deliver)
},
})
const frames = async (count = 2) => {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
const snapshot = (): Snapshot => {
const rows = mountedRows()
const view = viewport?.getBoundingClientRect()
const visibleRows =
viewport?.isConnected && view
? rows.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
}).length
: 0
return {
mode: { resource: resourceMode, reconnect: reconnectMode },
operation: {
sequence: ++snapshotSequence,
phase,
time: performance.now(),
frame: browserFrame,
},
resourceState: resource.state,
routeConnected: route?.isConnected ?? false,
viewportConnected: viewport?.isConnected ?? false,
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
sameRoute: route === initialRoute,
sameViewport: viewport === initialViewport,
sameSurface: surface === initialSurface,
sameMountedRows:
initialRows.length > 0 &&
initialRows.length === rows.length &&
initialRows.every((row, index) => row === rows[index]),
nativeOffset: viewport?.scrollTop ?? -1,
coreOffset: virtualizer.scrollOffset ?? -1,
rangeStart: virtualizer.range?.startIndex ?? -1,
rangeEnd: virtualizer.range?.endIndex ?? -1,
indexes: virtualizer.getVirtualItems().map((item) => item.index),
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
viewportClientHeight: viewport?.clientHeight ?? -1,
viewportScrollHeight: viewport?.scrollHeight ?? -1,
visibleRows,
minimumRowTop:
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
domScrollEvents,
lastScrollTrusted,
coreOffsetCallbackCalls,
offsetCallbackSources: [...offsetCallbackSources],
rectObserverCallbacks,
ignoredDetachedZeroRects,
syntheticScrollDispatches: 0,
mutationEvents: mutationEvents.map((event) => ({ ...event })),
}
}
onMount(() => {
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
const routeRoot = route.parentElement
if (!routeRoot) throw new Error("Timeline route root did not mount")
initialRoute = route
initialViewport = viewport
initialSurface = surface
viewport.addEventListener("scroll", (event) => {
domScrollEvents++
lastScrollTrusted = event.isTrusted
})
const countFrames = () => {
browserFrame++
requestAnimationFrame(countFrames)
}
requestAnimationFrame(countFrames)
new MutationObserver((records) => {
const callbackTime = performance.now()
records.forEach((record) => {
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
if (node !== route) return
phase = "detached"
mutationEvents.push({
kind: "removed",
callbackTime,
callbackFrame: browserFrame,
routeConnectedInCallback: route.isConnected,
nativeOffsetInCallback: viewport.scrollTop,
})
})
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
if (node !== route) return
phase = "reinserted"
mutationEvents.push({
kind: "added",
callbackTime,
callbackFrame: browserFrame,
routeConnectedInCallback: route.isConnected,
nativeOffsetInCallback: viewport.scrollTop,
})
})
})
}).observe(routeRoot, { childList: true })
window.timelineSuspense = {
prepare: async () => {
phase = "preparing"
await frames(2)
viewport.scrollTop = viewport.scrollHeight
await frames(3)
await new Promise((resolve) => setTimeout(resolve, 200))
await frames(2)
initialRows = mountedRows()
phase = "prepared"
return snapshot()
},
trigger: () => {
phase = "triggering"
setRefresh(true)
},
resolve: () => {
if (!resolveResource) throw new Error("Resource is not pending")
phase = "resolving"
resolveResource()
},
frames,
snapshot,
}
})
return (
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
<span aria-hidden="true" style={{ display: "none" }}>
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
</span>
<div
ref={viewport}
data-viewport
style={{
height: "600px",
overflow: "auto",
"overflow-anchor": "none",
position: "relative",
background: "#202020",
outline: "1px solid #3f3f46",
}}
>
<div
ref={surface}
data-surface
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
>
<For each={virtualizer.getVirtualItems()}>
{(item) => (
<div
data-row-index={item.index}
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: `${item.size}px`,
transform: `translateY(${item.start}px)`,
padding: "10px 14px",
border: "0 solid #333",
"border-bottom-width": "1px",
}}
>
logical row {item.index}
</div>
)}
</For>
</div>
</div>
</section>
)
}
return (
<main>
<Suspense>
<Route />
</Suspense>
</main>
)
}
render(() => <App />, document.getElementById("root")!)

View file

@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
export default defineConfig({
testDir: ".",
testMatch: "timeline-suspense.repro.ts",
outputDir: "../../test-results/timeline-suspense",
fullyParallel: false,
workers: 1,
retries: 0,
reporter: "line",
timeout: 30_000,
expect: {
timeout: 10_000,
},
webServer: {
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
cwd: import.meta.dirname,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: false,
},
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})

View file

@ -0,0 +1,179 @@
import { expect, test, type Page } from "@playwright/test"
test.beforeEach(async ({ page }) => {
page.on("pageerror", (error) => console.error(error))
await page.goto("/")
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
})
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
await page.goto("/?reconnect=candidate")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
const before = await prepare(page)
await triggerBaselineSuspension(page)
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.nativeOffset).toBe(0)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.indexes).toEqual(before.indexes)
expect(pending.sameRoute).toBe(true)
expect(pending.sameViewport).toBe(true)
expect(pending.sameSurface).toBe(true)
expect(pending.sameMountedRows).toBe(true)
await resolveSuspension(page)
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
await page.waitForTimeout(250)
await page.evaluate(() => window.timelineSuspense.frames(2))
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(after.sameRoute).toBe(true)
expect(after.sameViewport).toBe(true)
expect(after.sameSurface).toBe(true)
expect(after.nativeOffset).toBe(0)
expect(after.coreOffset).toBe(0)
expect(after.rangeStart).toBeLessThan(10)
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
expect(after.domScrollEvents).toBe(before.domScrollEvents)
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
expect(after.syntheticScrollDispatches).toBe(0)
})
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
page,
}) => {
const before = await prepare(page)
const beforeRows = before.domIndexes
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
expect(before.logicalSurfaceHeight).toBe(80_000)
expect(before.renderedSurfaceHeight).toBe(80_000)
expect(before.viewportClientHeight).toBe(600)
expect(before.viewportScrollHeight).toBe(80_000)
expect(before.rangeStart).toBeGreaterThan(1_900)
expect(before.nativeOffset).toBe(before.coreOffset)
expect(before.visibleRows).toBeGreaterThan(0)
await triggerBaselineSuspension(page)
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.resourceState).toBe("refreshing")
expect(pending.routeConnected).toBe(false)
expect(pending.viewportConnected).toBe(false)
expect(pending.viewportOwnedByRoute).toBe(true)
expect(pending.nativeOffset).toBe(0)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.rangeStart).toBe(before.rangeStart)
expect(pending.rangeEnd).toBe(before.rangeEnd)
expect(pending.indexes).toEqual(before.indexes)
expect(pending.domIndexes).toEqual(beforeRows)
expect(pending.sameMountedRows).toBe(true)
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
expect(pending.mutationEvents).toHaveLength(1)
expect(pending.mutationEvents[0]).toMatchObject({
kind: "removed",
routeConnectedInCallback: false,
nativeOffsetInCallback: 0,
})
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
const after = await resolveSuspension(page)
expect(after.resourceState).toBe("ready")
expect(after.routeConnected).toBe(true)
expect(after.viewportConnected).toBe(true)
expect(after.viewportOwnedByRoute).toBe(true)
expect(after.sameRoute).toBe(true)
expect(after.sameViewport).toBe(true)
expect(after.sameSurface).toBe(true)
expect(after.sameMountedRows).toBe(true)
expect(after.nativeOffset).toBe(0)
expect(after.coreOffset).toBe(before.coreOffset)
expect(after.rangeStart).toBe(before.rangeStart)
expect(after.rangeEnd).toBe(before.rangeEnd)
expect(after.indexes).toEqual(before.indexes)
expect(after.domIndexes).toEqual(beforeRows)
expect(after.domScrollEvents).toBe(before.domScrollEvents)
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
expect(after.mutationEvents).toHaveLength(2)
expect(after.mutationEvents[1]).toMatchObject({
kind: "added",
routeConnectedInCallback: true,
nativeOffsetInCallback: 0,
})
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
expect(after.visibleRows).toBe(0)
expect(after.minimumRowTop).toBeGreaterThan(50_000)
expect(after.syntheticScrollDispatches).toBe(0)
await page.locator("[data-viewport]").hover()
await page.mouse.wheel(0, 80)
await expect
.poll(() =>
page.evaluate(() => {
const value = window.timelineSuspense.snapshot()
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
}),
)
.toBe(true)
await page.evaluate(() => window.timelineSuspense.frames(2))
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
expect(recovered.lastScrollTrusted).toBe(true)
expect(recovered.rangeStart).toBeLessThan(10)
expect(recovered.visibleRows).toBeGreaterThan(0)
})
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
await page.goto("/?resource=guard")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
const before = await prepare(page)
await page.evaluate(() => window.timelineSuspense.trigger())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
await page.evaluate(() => window.timelineSuspense.frames(3))
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.routeConnected).toBe(true)
expect(pending.mutationEvents).toEqual([])
expect(pending.nativeOffset).toBe(before.nativeOffset)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.visibleRows).toBeGreaterThan(0)
const after = await resolveSuspension(page)
expect(after.routeConnected).toBe(true)
expect(after.nativeOffset).toBe(before.nativeOffset)
expect(after.coreOffset).toBe(before.coreOffset)
expect(after.visibleRows).toBeGreaterThan(0)
})
async function prepare(page: Page) {
const before = await page.evaluate(() => window.timelineSuspense.prepare())
expect(before.routeConnected).toBe(true)
expect(before.viewportConnected).toBe(true)
expect(before.viewportOwnedByRoute).toBe(true)
expect(before.sameMountedRows).toBe(true)
expect(before.rangeStart).toBeGreaterThan(1_900)
expect(before.nativeOffset).toBe(before.coreOffset)
return before
}
async function triggerBaselineSuspension(page: Page) {
await page.evaluate(() => window.timelineSuspense.trigger())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
await page.evaluate(() => window.timelineSuspense.frames(3))
}
async function resolveSuspension(page: Page) {
await page.evaluate(() => window.timelineSuspense.resolve())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
await page.evaluate(() => window.timelineSuspense.frames(3))
return page.evaluate(() => window.timelineSuspense.snapshot())
}
function diagnostic(value: unknown) {
return JSON.stringify(value, null, 2)
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
export default defineConfig({
root: import.meta.dirname,
plugins: [solid()],
})

View file

@ -10,6 +10,9 @@
"./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts", "./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts", "./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts", "./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts", "./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts" "./utils/**/*.ts"

View file

@ -162,7 +162,7 @@ export async function installSseTransport<T>(
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
return originalFetch(input, init) return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
const record = { const record = {

View file

@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() {
} }
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // server via ServerKey, then provide the server-scoped shell for that server.
// Notification/Models + the visual Layout) for that server.
function SelectedServerProviders(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<DraftServerScopedProviders directory={directory}> <ModelsProvider directory={directory}>
<SDKProvider directory={directory}> <SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}> <DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders> <DraftProviders>
@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
</DraftProviders> </DraftProviders>
</DirectoryDataProvider> </DirectoryDataProvider>
</SDKProvider> </SDKProvider>
</DraftServerScopedProviders> </ModelsProvider>
</ServerSyncProvider> </ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</Show> </Show>
@ -309,24 +308,21 @@ function DesktopCommands() {
// Server-scoped providers shared by the legacy shell and the top-level new shell. // Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
sessionID?: () => string | undefined
serverScoped?: JSX.Element serverScoped?: JSX.Element
}> }>
function ServerScopedProviders(props: ServerScopedShellProps) { function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <LayoutProvider>
<LayoutProvider> {props.serverScoped}
{props.serverScoped} <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> </LayoutProvider>
</LayoutProvider>
</PermissionProvider>
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) { function LegacyServerScopedShell(props: ServerScopedShellProps) {
return ( return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}> <ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout> <LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders> </ServerScopedProviders>
) )
@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
) )
} }
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
return (
<PermissionProvider directory={props.directory}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider. // The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) { function DraftProviders(props: ParentProps) {
@ -559,13 +547,15 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<NotificationProvider> <PermissionProvider>
<ServerShell> <NotificationProvider>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <ServerShell>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
</Show> <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</ServerShell> </Show>
</NotificationProvider> </ServerShell>
</NotificationProvider>
</PermissionProvider>
</TabsProvider> </TabsProvider>
)} )}
> >

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View file

@ -1,11 +1,12 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js" import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import { Persist, persisted } from "@/utils/persist" import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
const helpIcon = ( const helpIcon = (
<svg <svg
@ -54,24 +55,21 @@ export function HelpButton() {
// can remove this after the tabs rollout has been out for a while // can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() { export function TabsInfoPopup() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null const settings = useSettings()
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
// setState({ dismissed: false }) // for testing
const [drawerOpen, setDrawerOpen] = createSignal(false) const [drawerOpen, setDrawerOpen] = createSignal(false)
return ( return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right"> <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={!state.dismissed}> <Show when={settings.general.shouldDisplayTabsToast()}>
<div <div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]" class="fixed bottom-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 <button
type="button" type="button"
aria-label="Dismiss Tabs information" aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]" class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={() => setState("dismissed", true)} onClick={settings.general.dismissTabsToast}
> >
<svg <svg
width="16" width="16"
@ -88,7 +86,7 @@ export function TabsInfoPopup() {
type="button" type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left" class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => { onClick={() => {
setState("dismissed", true) settings.general.dismissTabsToast()
setDrawerOpen(true) setDrawerOpen(true)
}} }}
> >
@ -107,7 +105,7 @@ export function TabsInfoPopup() {
Introducing Tabs Introducing Tabs
</p> </p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]"> <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> </p>
</div> </div>
</button> </button>
@ -127,16 +125,32 @@ export function TabsInfoPopup() {
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
/> />
</div> </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"> <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. Introducing Tabs
</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.
</p> </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> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

View file

@ -2203,10 +2203,7 @@ type ComposerModelControlState = {
function ComposerAgentControl(props: { state: ComposerAgentControlState }) { function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return ( return (
<div class="relative"> <div>
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipV2 <TooltipV2
placement="top" placement="top"
gutter={4} gutter={4}
@ -2217,17 +2214,32 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
</> </>
} }
> >
<Select <MenuV2 gutter={6} modal={false} placement="top-start">
size="normal" <MenuV2.Trigger
options={props.state.options} as={ButtonV2}
current={props.state.current} data-action="prompt-agent"
onSelect={props.state.onSelect} variant="ghost-muted"
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted" size="normal"
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint" class="max-w-[175px] justify-start ![font-weight:440]"
triggerStyle={props.state.style} style={props.state.style}
triggerProps={{ "data-action": "prompt-agent" }} >
variant="ghost" <span class="truncate capitalize leading-5">{props.state.current}</span>
/> <span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" size="small" />
</span>
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.RadioGroup value={props.state.current} onChange={props.state.onSelect}>
{props.state.options.map((value) => (
<MenuV2.RadioItem value={value} class="capitalize">
{value}
</MenuV2.RadioItem>
))}
</MenuV2.RadioGroup>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2> </TooltipV2>
</div> </div>
) )

View file

@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = [] const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
sessionID?: string sessionID?: string
@ -27,6 +27,8 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = { const prompt = {
@ -56,6 +58,7 @@ const clientFor = (directory: string) => {
return { return {
session: { session: {
create: async () => { create: async () => {
await createSessionGate
createdSessions.push(directory) createdSessions.push(directory)
return { return {
data: { data: {
@ -122,13 +125,14 @@ beforeAll(async () => {
}), }),
})) }))
mock.module("@/context/permission", () => ({ mock.module("@/context/permission", () => {
usePermission: () => ({ const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ sessionID, directory }) enabledAutoAccept.push({ server, sessionID, directory })
}, },
}), })
})) return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
})
mock.module("@/context/server", () => ({ mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }), useServer: () => ({ key: "server-key" }),
@ -251,6 +255,8 @@ beforeEach(() => {
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
@ -318,7 +324,40 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("keeps auto-accept bound to the submission server", async () => {
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
release()
await result
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
}) })
test("promotes drafts using the selected project's server", async () => { test("promotes drafts using the selected project's server", async () => {

View file

@ -313,6 +313,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation() input.resetHistoryNavigation()
const projectDirectory = sdk().directory const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
@ -376,7 +377,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
session = created session = created
await startTransition(() => { await startTransition(() => {
if (!session) return if (!session) return
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, { local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name, agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id }, model: { providerID: currentModel.provider.id, modelID: currentModel.id },

View file

@ -1,4 +1,13 @@
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js" import {
createEffect,
createSignal,
For,
onCleanup,
Show,
splitProps,
type Accessor,
type ComponentProps,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@ -184,9 +193,28 @@ export function PromptProjectSelector(props: {
controller: PromptProjectController controller: PromptProjectController
placement?: "bottom" | "bottom-start" placement?: "bottom" | "bottom-start"
}) { }) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
let triggerFrame: number | undefined
let restoreTrigger = true let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => {
const ready = () => {
if (!element.isConnected) {
triggerFrame = requestAnimationFrame(ready)
return
}
triggerFrame = undefined
setTriggerReady(true)
}
ready()
}
onCleanup(() => {
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
})
const activeItem = () => const activeItem = () =>
props.controller.active() props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`) ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
@ -257,13 +285,13 @@ export function PromptProjectSelector(props: {
return ( return (
<DropdownMenu <DropdownMenu
open={props.controller.open()} open={triggerReady() && props.controller.open()}
placement={props.placement ?? "bottom"} placement={props.placement ?? "bottom"}
gutter={4} gutter={4}
modal={false} modal={false}
onOpenChange={(open) => props.controller.setOpen(open)} onOpenChange={(open) => props.controller.setOpen(open)}
> >
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} /> <DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content <DropdownMenu.Content
ref={contentRef} ref={contentRef}

View file

@ -7,7 +7,7 @@ export function NewSessionDesignView(props: { children: JSX.Element }) {
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep "> <div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6"> <div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}> <div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" /> <WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div> <div class="mt-8">{props.children}</div>
</div> </div>
</div> </div>

View file

@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
const focus = () => { const focus = () => {
if (store.editing) return if (store.editing) return
terminal.requestFocus(props.terminal.id)
terminal.open(props.terminal.id) terminal.open(props.terminal.id)
if (document.activeElement instanceof HTMLElement) document.activeElement.blur() if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id) focusTerminalById(props.terminal.id)
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
} }
const edit = (e?: Event) => { const edit = (e?: Event) => {

View file

@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
triggerVariant: "settings" as const, triggerVariant: "settings" as const,
}) })
const InterfaceSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={
<span class="flex items-center gap-2">
{language.t("settings.general.row.newInterface.title")}
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
</span>
}
description={language.t("settings.general.row.newInterface.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const InterfaceNoticeSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
>
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const GeneralSection = () => ( const GeneralSection = () => (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<SettingsList> <SettingsList>
@ -334,24 +379,6 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList> </SettingsList>
</div> </div>
) )
@ -718,6 +745,14 @@ export const SettingsGeneral: Component = () => {
</div> </div>
<div class="flex flex-col gap-8 w-full"> <div class="flex flex-col gap-8 w-full">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection /> <GeneralSection />
<AppearanceSection /> <AppearanceSection />

View file

@ -28,6 +28,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link" import { Link } from "../link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import "./settings-v2.css" import "./settings-v2.css"
let demoSoundState = { let demoSoundState = {
@ -226,6 +227,31 @@ export const SettingsGeneralV2: Component<{
}, },
}) })
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
badge={language.t("settings.general.row.newInterface.badge")}
description={language.t("settings.general.row.newInterface.description")}
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
)
const InterfaceNoticeSection = () => (
<LayoutRetirementNotice
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice}
/>
)
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<SettingsListV2> <SettingsListV2>
@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
</div> </div>
</SettingsRowV2> </SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRowV2>
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}> <Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.mobileTitlebarBottom.title")} title={language.t("settings.general.row.mobileTitlebarBottom.title")}
@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
</div> </div>
<div class="settings-v2-tab-body"> <div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection /> <GeneralSection />
<AppearanceSection /> <AppearanceSection />

View file

@ -0,0 +1,76 @@
// @ts-nocheck
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
const copy = {
title: "New layout",
badge: "New",
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
noticeTitle: "You're now using new layout",
noticeDescription: "The previous layout is no longer available",
dismiss: "Dismiss",
}
function Frame(props) {
return <div class="w-[640px] max-w-full">{props.children}</div>
}
function ToggleExample(props) {
const [state, setState] = createStore({ checked: props.checked })
return (
<Frame>
<LayoutTransitionToggle
title={copy.title}
badge={copy.badge}
description={copy.description}
checked={state.checked}
onChange={(checked) => setState("checked", checked)}
/>
</Frame>
)
}
function NoticeExample() {
const [state, setState] = createStore({ dismissed: false })
return (
<Frame>
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
<LayoutRetirementNotice
title={copy.noticeTitle}
description={copy.noticeDescription}
dismiss={copy.dismiss}
onDismiss={() => setState("dismissed", true)}
/>
</Show>
</Frame>
)
}
export default {
title: "App/Settings/Layout transition",
id: "app-settings-layout-transition",
component: LayoutTransitionToggle,
}
export const NewLayoutEnabled = {
render: () => <ToggleExample checked />,
}
export const PreviousLayoutEnabled = {
render: () => <ToggleExample checked={false} />,
}
export const PreviousLayoutRetired = {
render: () => <NoticeExample />,
}
export const AllStates = {
render: () => (
<div class="flex flex-col gap-8">
<ToggleExample checked />
<ToggleExample checked={false} />
<NoticeExample />
</div>
),
}

View file

@ -0,0 +1,54 @@
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
export function LayoutTransitionToggle(props: {
title: string
badge: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<div class="settings-v2-section">
<div class="settings-v2-interface-feature">
<SettingsListV2>
<SettingsRowV2
title={
<span class="flex items-center gap-2">
{props.title}
<Tag variant="accent">{props.badge}</Tag>
</span>
}
description={props.description}
>
<div data-action="settings-new-layout-designs">
<Switch checked={props.checked} onChange={props.onChange} />
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
</div>
)
}
export function LayoutRetirementNotice(props: {
title: string
description: string
dismiss: string
onDismiss: () => void
}) {
return (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2 title={props.title} description={props.description}>
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
{props.dismiss}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
}

View file

@ -90,6 +90,11 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
} }
.settings-v2-interface-feature [data-component="settings-v2-list"] {
background-color: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
}
[data-component="settings-v2-row"] { [data-component="settings-v2-row"] {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -128,7 +133,7 @@
} }
[data-slot="settings-v2-row-description"] { [data-slot="settings-v2-row-description"] {
font-size: 11px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 1; line-height: 1;
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);

View file

@ -23,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
autoFocus?: boolean autoFocus?: boolean
onAutoFocus?: () => void
onSubmit?: () => void onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void onConnect?: () => void
@ -185,7 +186,15 @@ export const Terminal = (props: TerminalProps) => {
const authToken = connection.type === "http" ? connection.authToken : false const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, [
"pty",
"class",
"classList",
"autoFocus",
"onAutoFocus",
"onConnect",
"onConnectError",
])
const id = local.pty.id const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
@ -416,6 +425,7 @@ export const Terminal = (props: TerminalProps) => {
fitAddon = fit fitAddon = fit
serializeAddon = serializer serializeAddon = serializer
const active = document.activeElement
t.open(container) t.open(container)
useTerminalUiBindings({ useTerminalUiBindings({
container, container,
@ -425,7 +435,22 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick, handleLinkClick,
}) })
if (local.autoFocus !== false) focusTerminal() if (local.autoFocus === true) {
focusTerminal()
local.onAutoFocus?.()
}
if (local.autoFocus !== true) {
const restoreFocus = () => {
const current = document.activeElement
if (current !== container && !container.contains(current)) return
t.blur()
t.textarea?.blur()
if (active instanceof HTMLElement && active.isConnected) active.focus()
}
restoreFocus()
const timer = setTimeout(restoreFocus, 0)
cleanups.push(() => clearTimeout(timer))
}
if (typeof document !== "undefined" && document.fonts) { if (typeof document !== "undefined" && document.fonts) {
void document.fonts.ready.then(scheduleFit) void document.fonts.ready.then(scheduleFit)

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) => const session = (input: { id: string; parentID?: string }) =>
({ ({
@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
} }
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
}) })
test("session-level override takes precedence over directory-level", () => { test("session-level override takes precedence over directory-level", () => {
@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
}) })
test("parent false override takes precedence over directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("parent true override takes precedence over disabled directory fallback", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: false,
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
}) })
describe("isDirectoryAutoAccepting", () => { describe("isDirectoryAutoAccepting", () => {

View file

@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) { function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory) const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined return autoAccept[key] ?? autoAccept[sessionID]
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
} }
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) { export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
@ -44,8 +43,18 @@ export function autoRespondsPermission(
permission: { sessionID: string }, permission: { sessionID: string },
directory?: string, directory?: string,
) { ) {
const value = sessionLineage(session, permission.sessionID) const value = sessionAutoAccept(autoAccept, session, permission, directory)
if (value !== undefined) return value
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
}
export function sessionAutoAccept(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
return sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory)) .map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined) .find((item): item is boolean => item !== undefined)
return value ?? false
} }

View file

@ -1,17 +1,24 @@
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk" import type { ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
import { useParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { import {
acceptKey, acceptKey,
directoryAcceptKey, directoryAcceptKey,
isDirectoryAutoAccepting, isDirectoryAutoAccepting,
autoRespondsPermission, autoRespondsPermission,
sessionAutoAccept,
} from "./permission-auto-respond" } from "./permission-auto-respond"
type PermissionRespondFn = (input: { type PermissionRespondFn = (input: {
@ -47,234 +54,417 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false, gate: false,
init: (props: { directory?: Accessor<string | undefined> }) => { init: () => {
const params = useParams() const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const serverSDK = useServerSDK() const [search] = useSearchParams<{ draftId?: string }>()
const serverSync = useServerSync() const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const owner = getOwner()
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
const activeDraft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const activeServer = createMemo(() => {
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
return activeDraft()?.server ?? server.key
})
const ensure = (key: ServerConnection.Key) => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Permission server not found: ${key}`)
const ctx = global.ensureServerCtx(conn)
const existing = states.get(ctx.sdk.scope)
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
return existing.state
}
if (existing) {
existing.dispose()
states.delete(ctx.sdk.scope)
}
const root = createRoot(
(dispose) => ({
key,
dispose,
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
}),
owner ?? undefined,
)
states.set(ctx.sdk.scope, root)
return root.state
}
createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
})
createEffect(() => {
const list = global.servers.list()
const keys = new Set(list.map(ServerConnection.key))
states.forEach((value, scope) => {
if (keys.has(value.key)) return
value.dispose()
states.delete(scope)
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
if (replacement) ensure(ServerConnection.key(replacement))
})
})
onCleanup(() => states.forEach((value) => value.dispose()))
let lastSelected: PermissionState | undefined
const selected = () => {
const key = activeServer()
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
lastSelected = ensure(key)
}
if (lastSelected) return lastSelected
return ensure(server.key)
}
const activeDirectory = createMemo(() => {
const directory = decode64(params.dir)
if (directory) return directory
const draft = activeDraft()
if (draft) return draft.directory
if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.directory
})
createEffect(() => {
const directory = activeDirectory()
if (!directory) return
selected().enableConfiguredDirectory(directory)
})
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
const directory = props.directory?.() ?? decode64(params.dir) const directory = activeDirectory()
if (!directory) return false if (!directory) return false
const [store] = serverSync().child(directory) return selected().permissionsEnabled(directory)
return hasPermissionPromptRules(store.config.permission)
}) })
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) {
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
}
})
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (input) => {
serverSDK()
.client.permission.respond(input)
.catch(() => {
responded.delete(input.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const unsubscribe = serverSDK().event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!shouldAutoRespond(perm, e.name)) return
respondOnce(perm, e.name)
})
onCleanup(unsubscribe)
function enableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
return { return {
ready, ready: () => selected().ready(),
respond, ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
autoResponds(permission: PermissionRequest, directory?: string) { currentServerState: () => selected().api,
return shouldAutoRespond(permission, directory) respond(input: Parameters<PermissionRespondFn>[0]) {
selected().respond(input)
},
autoResponds(permission: PermissionRequest, directory?: string) {
return selected().autoResponds(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
return selected().isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
return selected().isAutoAcceptingDirectory(directory)
}, },
isAutoAccepting,
isAutoAcceptingDirectory,
toggleAutoAccept(sessionID: string, directory: string) { toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) { selected().toggleAutoAccept(sessionID, directory)
disable(sessionID, directory)
return
}
enable(sessionID, directory)
}, },
toggleAutoAcceptDirectory(directory: string) { toggleAutoAcceptDirectory(directory: string) {
if (isAutoAcceptingDirectory(directory)) { selected().toggleAutoAcceptDirectory(directory)
disableDirectory(directory)
return
}
enableDirectory(directory)
}, },
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) return selected().enableAutoAccept(sessionID, directory)
enable(sessionID, directory)
}, },
disableAutoAccept(sessionID: string, directory?: string) { disableAutoAccept(sessionID: string, directory?: string) {
disable(sessionID, directory) selected().disableAutoAccept(sessionID, directory)
}, },
permissionsEnabled, permissionsEnabled,
isPermissionAllowAll(directory: string) { isPermissionAllowAll(directory: string) {
const [childStore] = serverSync().child(directory) return selected().isPermissionAllowAll(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
}, },
} }
}, },
}) })
type PermissionState = ReturnType<typeof createServerPermissionState>
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
function enableConfiguredDirectory(directory: string) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
const meta = { disposed: false }
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.client.permission.respond(request).catch(() => {
responded.delete(request.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function sessions(directory?: string) {
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
if (!directory) return info
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
}
function isAutoAccepting(sessionID: string, directory?: string) {
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
}
function isPending(permission: PermissionRequest) {
const pending = input.sync.session.data.permission[permission.sessionID]
return pending === undefined || pending.some((item) => item.id === permission.id)
}
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
if (override !== undefined) return override
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
if (meta.disposed || !lineage) return false
return shouldAutoRespond(permission, directory)
}
async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const handlePermission = (e: PermissionEvent) => {
const event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name)
}
const unsubscribe = input.sdk.event.listen((event) => {
if (ready()) {
handlePermission(event)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
handlePermission(event)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
})
function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(
perm,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
const api = {
ready: () => !meta.disposed && ready(),
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
if (meta.disposed) return false
return isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
if (meta.disposed) return false
return isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory)
},
isPermissionAllowAll(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return childStore.config.permission === "allow"
},
}
return {
...api,
api,
sync: input.sync,
enableConfiguredDirectory,
permissionsEnabled(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return hasPermissionPromptRules(childStore.config.permission)
},
}
}

View file

@ -173,7 +173,7 @@ describe("server session", () => {
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }]) expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }]) expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
expect(ctx.store.data.message.root).toEqual([]) expect(ctx.store.data.message.root).toEqual([])
}) })
@ -193,7 +193,7 @@ describe("server session", () => {
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true) expect(store.history.more("child")).toBe(true)

View file

@ -20,7 +20,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2 const initialMessagePageSize = 20
const historyMessagePageSize = 200 const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()

View file

@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test"
import {
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
newLayoutDesignsDefault,
nextSunsetCheckDelay,
resolveNewLayoutDesigns,
shouldDisplayTabsToast,
shouldEnableNewLayout,
} from "./settings"
describe("layout transition", () => {
test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true)
})
test("hides the transition until a sunset is scheduled", () => {
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
})
test("existing profiles can switch before sunset", () => {
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
})
test("preserves explicit and default layout preferences", () => {
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
})
test("sunset replaces the toggle with a dismissible notice", () => {
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
})
test("caps checks for sunsets beyond the browser timeout limit", () => {
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
})
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
})
test("enables the new layout when no previous version was recorded", () => {
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
})
test("detects upgrades only when a previous version is older", () => {
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
})
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
})
test("does not enable the new layout without a qualifying upgrade", () => {
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
})
})

View file

@ -1,7 +1,8 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
export interface NotificationSettings { export interface NotificationSettings {
agent: boolean agent: boolean
@ -34,6 +35,9 @@ export interface Settings {
showCustomAgents: boolean showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom" mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
} }
appearance: { appearance: {
fontSize: number fontSize: number
@ -52,7 +56,70 @@ export interface Settings {
export const monoDefault = "System Mono" export const monoDefault = "System Mono"
export const sansDefault = "System Sans" export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono" export const terminalDefault = "JetBrainsMono Nerd Font Mono"
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export const newLayoutDesignsDefault = true
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
export const oldInterfaceSunset = new Date(2026, 8, 14)
const newLayoutDesignsUpgradeCutoff = "1.17.19"
function compareVersions(a: string, b: string) {
const parse = (version: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
if (!match) return
return match.slice(1).map(Number)
}
const left = parse(a)
const right = parse(b)
if (!left || !right) return
const index = left.findIndex((part, index) => part !== right[index])
return index === -1 ? 0 : left[index]! - right[index]!
}
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
if (!previous || !current) return false
const comparison = compareVersions(current, previous)
return comparison !== undefined && comparison > 0
}
export function shouldDisplayTabsToast(
previous: string | undefined,
current: string | undefined,
existingInstall: boolean,
) {
return isAppUpgrade(previous, current) || (!previous && existingInstall)
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
if (!previous) return currentComparison !== undefined && currentComparison > 0
if (!isAppUpgrade(previous, current)) return false
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
return (
previousComparison !== undefined &&
currentComparison !== undefined &&
previousComparison <= 0 &&
currentComparison > 0
)
}
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
return {
available: scheduled && eligible && !retired,
notice: scheduled && eligible && retired && !dismissed,
}
}
export const maximumSunsetTimeout = 2_147_483_647
export function nextSunsetCheckDelay(sunset: number, now: number) {
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
}
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
if (retired) return true
return preference ?? fallback
}
const monoFallback = const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@ -152,7 +219,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings", name: "Settings",
gate: false, gate: false,
init: () => { init: () => {
const platform = usePlatform()
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings)) const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [launch, setLaunch, , launchReady] = persisted(
"app-version.v1",
createStore<{ version?: string }>({ version: undefined }),
)
const [launchState, setLaunchState] = createStore({
classified: false,
migrationApplied: false,
previous: undefined as string | undefined,
})
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree) const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch) const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus) const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@ -160,9 +237,87 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
() => store.general?.showCustomAgents, () => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents, defaultSettings.general.showCustomAgents,
) )
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault) const sunset = oldInterfaceSunset
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
const layoutUpgrade = createMemo(() =>
launchState.classified && !launchState.migrationApplied
? shouldEnableNewLayout(launchState.previous, platform.version)
: false,
)
const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
)
const newLayoutDesigns = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) {
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
legacyNewLayoutDesignsDefault,
)
}
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
)
})
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const checkSunset = () => {
if (Date.now() >= sunset.getTime()) {
setOldInterfaceRetired(true)
return
}
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
}
checkSunset()
onCleanup(() => {
if (timeout.current !== undefined) clearTimeout(timeout.current)
})
}
createEffect(() => {
if (!launchReady() || launchState.classified) return
setLaunchState({
classified: true,
previous: launch.version,
})
if (!platform.version || launch.version === platform.version) return
setLaunch("version", platform.version)
})
createEffect(() => {
if (!ready() || !launchState.classified || launchState.migrationApplied) return
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
setStore("general", "newLayoutDesigns", true)
}
setLaunchState("migrationApplied", true)
})
createEffect(() => {
if (!ready() || !launchState.classified) return
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
if (!launchState.previous && !layoutTransitionClassified()) return
setStore(
"general",
"shouldDisplayTabsToast",
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
)
})
createEffect(() => {
if (!ready() || !oldInterfaceRetired()) return
if (store.general?.newLayoutDesigns === true) return
setStore("general", "newLayoutDesigns", true)
})
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const root = document.documentElement const root = document.documentElement
@ -250,7 +405,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
}, },
newLayoutDesigns, newLayoutDesigns,
setNewLayoutDesigns(value: boolean) { setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value) const next = oldInterfaceRetired() ? true : value
if (newLayoutDesigns() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
},
layoutTransitionClassified,
setOldLayoutEligible(eligible: boolean) {
const current = store.general?.layoutTransitionEligible
if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible)
},
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
setStore("general", "newInterfaceNoticeDismissed", true)
},
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
dismissTabsToast() {
setStore("general", "shouldDisplayTabsToast", false)
}, },
}, },
visibility: { visibility: {

View file

@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab return tab
}, },
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) { async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
const draftID = uuid() const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft } const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model })) memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
void startTransition(() => { await startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
tabs.push(tab) tabs.push(tab)
@ -220,6 +220,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
) )
navigate(draftHref(draftID)) navigate(draftHref(draftID))
}) })
return tab
}, },
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) { updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
void startTransition(() => { void startTransition(() => {

View file

@ -163,6 +163,43 @@ function createWorkspaceTerminalSession(
all: [], all: [],
}), }),
) )
const [ui, setUi] = createStore({
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
})
const focus = { request: 0 }
const requestFocus = (id?: string, pending = false) => {
focus.request += 1
setUi("focus", { request: focus.request, id, pending })
return focus.request
}
const focusRequested = (id?: string) => {
if (!id) return false
if (!ui.focus || ui.focus.pending) return false
return !ui.focus.id || ui.focus.id === id
}
const consumeFocus = (id: string) => {
if (!focusRequested(id)) return
setUi("focus", undefined)
}
const cancelFocus = (request?: number) => {
if (request !== undefined && ui.focus?.request !== request) return
setUi("focus", undefined)
}
if (typeof document !== "undefined") {
const cancelOnOutsideFocus = (event: FocusEvent) => {
if (!ui.focus) return
if (!(event.target instanceof Element)) return
if (event.target.closest("#terminal-panel")) return
cancelFocus()
}
document.addEventListener("focusin", cancelOnOutsideFocus)
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
}
const pickNextTerminalNumber = () => { const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set( const existingTitleNumbers = new Set(
@ -267,23 +304,33 @@ function createWorkspaceTerminalSession(
setStore("all", []) setStore("all", [])
}) })
}, },
new() { new(options?: { focus?: boolean }) {
const nextNumber = pickNextTerminalNumber() const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
sdk.client.pty sdk.client.pty
.create({ title: defaultTitle(nextNumber) }) .create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => { .then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id const id = pty.data?.id
if (!id) return if (!id) {
if (focusRequest !== undefined) cancelFocus(focusRequest)
return
}
const newTerminal = { const newTerminal = {
id, id,
title: pty.data?.title ?? defaultTitle(nextNumber), title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber, titleNumber: nextNumber,
} }
setStore("all", store.all.length, newTerminal) batch(() => {
setStore("active", id) setStore("all", store.all.length, newTerminal)
setStore("active", id)
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
setUi("focus", { request: focusRequest, id, pending: false })
}
})
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (focusRequest !== undefined) cancelFocus(focusRequest)
console.error("Failed to create terminal", error) console.error("Failed to create terminal", error)
}) })
}, },
@ -324,6 +371,18 @@ function createWorkspaceTerminalSession(
open(id: string) { open(id: string) {
setStore("active", id) setStore("active", id)
}, },
requestFocus(id?: string) {
requestFocus(id)
},
focusRequested(id?: string) {
return focusRequested(id)
},
consumeFocus(id: string) {
consumeFocus(id)
},
cancelFocus() {
cancelFocus()
},
next() { next() {
const index = store.all.findIndex((x) => x.id === store.active) const index = store.all.findIndex((x) => x.id === store.active)
if (index === -1) return if (index === -1) return
@ -442,13 +501,17 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
ready: () => workspace().ready(), ready: () => workspace().ready(),
all: () => workspace().all(), all: () => workspace().all(),
active: () => workspace().active(), active: () => workspace().active(),
new: () => workspace().new(), new: (options?: { focus?: boolean }) => workspace().new(options),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty), update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
trim: (id: string) => workspace().trim(id), trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(), trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id), clone: (id: string) => workspace().clone(id),
bind: () => workspace(), bind: () => workspace(),
open: (id: string) => workspace().open(id), open: (id: string) => workspace().open(id),
requestFocus: (id?: string) => workspace().requestFocus(id),
focusRequested: (id?: string) => workspace().focusRequested(id),
consumeFocus: (id: string) => workspace().consumeFocus(id),
cancelFocus: () => workspace().cancelFocus(),
close: (id: string) => workspace().close(id), close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to), move: (id: string, to: number) => workspace().move(id, to),
next: () => workspace().next(), next: () => workspace().next(),

View file

@ -732,9 +732,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة", "settings.general.row.newInterface.title": "التخطيط الجديد",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "جديد",
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها", "settings.general.row.newInterface.description":
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص", "settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير", "settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.title": "استخدام Wayland الأصلي",

View file

@ -743,9 +743,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
"settings.general.row.newLayoutDesigns.title": "Novo layout e design", "settings.general.row.newInterface.title": "Novo layout",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Novo",
"Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados", "settings.general.row.newInterface.description":
"Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.",
"settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout",
"settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível",
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça", "settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom", "Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",

View file

@ -808,9 +808,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
"settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn", "settings.general.row.newInterface.title": "Novi raspored",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Novo",
"Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije", "settings.general.row.newInterface.description":
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem", "settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja", "Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",

View file

@ -801,9 +801,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.newLayoutDesigns.title": "Nyt layout og design", "settings.general.row.newInterface.title": "Nyt layout",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Ny",
"Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen", "settings.general.row.newInterface.description":
"Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.",
"settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout",
"settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt",
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
"settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.title": "Knib for at zoome",
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome", "settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
"settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.title": "Brug native Wayland",

View file

@ -756,9 +756,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.newLayoutDesigns.title": "Neues Layout und Design", "settings.general.row.newInterface.title": "Neues Layout",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Neu",
"Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren", "settings.general.row.newInterface.description":
"Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.",
"settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout",
"settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar",
"settings.general.row.newInterfaceNotice.dismiss": "Verwerfen",
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste", "settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben", "settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
"settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.title": "Natives Wayland verwenden",

View file

@ -851,9 +851,8 @@ export const dict = {
"settings.general.row.language.title": "Language", "settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode", "settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal Shell", "settings.general.row.shell.title": "Terminal shell",
"settings.general.row.shell.description": "settings.general.row.shell.description": "Shell used by the terminal and agent tools",
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
"settings.general.row.shell.autoDefault": "Auto (Default)", "settings.general.row.shell.autoDefault": "Auto (Default)",
"settings.general.row.shell.terminalOnly": "terminal only", "settings.general.row.shell.terminalOnly": "terminal only",
"settings.general.row.appearance.title": "Appearance", "settings.general.row.appearance.title": "Appearance",
@ -885,8 +884,9 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation", "settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description": "settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile", "Place the title bar and session tabs at the bottom of the screen on mobile",
"settings.general.row.showCustomAgents.title": "Custom agents", "settings.general.row.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer", "settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
@ -895,8 +895,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline", "Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.newLayoutDesigns.title": "New layout and designs", "settings.general.row.newInterface.title": "New layout",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.newInterface.badge": "New",
"settings.general.row.newInterface.description":
"Use the new tabs and home layout. Switch between layouts for a limited time.",
"settings.general.row.newInterfaceNotice.title": "You're now using new layout",
"settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available",
"settings.general.row.newInterfaceNotice.dismiss": "Dismiss",
"settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",

View file

@ -813,9 +813,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
"settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos", "settings.general.row.newInterface.title": "Nuevo diseño",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Nuevo",
"Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones", "settings.general.row.newInterface.description":
"Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.",
"settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño",
"settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible",
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar", "settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll", "Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",

View file

@ -753,9 +753,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
"settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs", "settings.general.row.newInterface.title": "Nouvelle mise en page",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Nouveau",
"Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session", "settings.general.row.newInterface.description":
"Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.",
"settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page",
"settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible",
"settings.general.row.newInterfaceNotice.dismiss": "Ignorer",
"settings.general.row.pinchZoom.title": "Pincer pour zoomer", "settings.general.row.pinchZoom.title": "Pincer pour zoomer",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer", "Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",

View file

@ -738,9 +738,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
"settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン", "settings.general.row.newInterface.title": "新しいレイアウト",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "新機能",
"刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします", "settings.general.row.newInterface.description":
"新しいタブとホーム画面のレイアウトを使用します。期間限定でレイアウトを切り替えられます。",
"settings.general.row.newInterfaceNotice.title": "新しいレイアウトを使用しています",
"settings.general.row.newInterfaceNotice.description": "以前のレイアウトは利用できなくなりました",
"settings.general.row.newInterfaceNotice.dismiss": "閉じる",
"settings.general.row.pinchZoom.title": "ピンチでズーム", "settings.general.row.pinchZoom.title": "ピンチでズーム",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します", "トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",

View file

@ -993,8 +993,13 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치", "settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트", "settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시", "settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
"settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인", "settings.general.row.newInterface.title": "새 레이아웃",
"settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화", "settings.general.row.newInterface.badge": "신규",
"settings.general.row.newInterface.description":
"새 탭과 홈 화면 레이아웃을 사용합니다. 제한된 기간 동안 레이아웃을 전환할 수 있습니다.",
"settings.general.row.newInterfaceNotice.title": "이제 새 레이아웃을 사용 중입니다",
"settings.general.row.newInterfaceNotice.description": "이전 레이아웃은 더 이상 사용할 수 없습니다",
"settings.general.row.newInterfaceNotice.dismiss": "닫기",
"settings.general.row.pinchZoom.title": "핀치 줌", "settings.general.row.pinchZoom.title": "핀치 줌",
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용", "settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
"settings.updates.action.downloading": "다운로드 중...", "settings.updates.action.downloading": "다운로드 중...",

View file

@ -1089,9 +1089,13 @@ export const dict = {
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen", "Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
"settings.general.row.showCustomAgents.title": "Egendefinerte agenter", "settings.general.row.showCustomAgents.title": "Egendefinerte agenter",
"settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet", "settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet",
"settings.general.row.newLayoutDesigns.title": "Ny layout og utforming", "settings.general.row.newInterface.title": "Nytt oppsett",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Ny",
"Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet", "settings.general.row.newInterface.description":
"Bruk de nye fanene og startsidens oppsett. I en begrenset periode kan du bytte mellom oppsettene.",
"settings.general.row.newInterfaceNotice.title": "Du bruker nå det nye oppsettet",
"settings.general.row.newInterfaceNotice.description": "Det forrige oppsettet er ikke lenger tilgjengelig",
"settings.general.row.newInterfaceNotice.dismiss": "Avvis",
"settings.general.row.pinchZoom.title": "Knip for å zoome", "settings.general.row.pinchZoom.title": "Knip for å zoome",
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome", "settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
"settings.updates.action.downloading": "Laster ned...", "settings.updates.action.downloading": "Laster ned...",

View file

@ -743,9 +743,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
"settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd", "settings.general.row.newInterface.title": "Nowy układ",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Nowość",
"Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji", "settings.general.row.newInterface.description":
"Używaj nowych kart i układu strony głównej. Przez ograniczony czas możesz przełączać się między układami.",
"settings.general.row.newInterfaceNotice.title": "Korzystasz teraz z nowego układu",
"settings.general.row.newInterfaceNotice.description": "Poprzedni układ nie jest już dostępny",
"settings.general.row.newInterfaceNotice.dismiss": "Odrzuć",
"settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl", "Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl",

View file

@ -809,9 +809,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
"settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление", "settings.general.row.newInterface.title": "Новая компоновка",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Новое",
"Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии", "settings.general.row.newInterface.description":
"Используйте новые вкладки и компоновку главной страницы. В течение ограниченного времени можно переключаться между вариантами компоновки.",
"settings.general.row.newInterfaceNotice.title": "Теперь вы используете новую компоновку",
"settings.general.row.newInterfaceNotice.description": "Прежняя компоновка больше недоступна",
"settings.general.row.newInterfaceNotice.dismiss": "Закрыть",
"settings.general.row.pinchZoom.title": "Масштабирование щипком", "settings.general.row.pinchZoom.title": "Масштабирование щипком",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl", "Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl",

View file

@ -798,9 +798,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่", "settings.general.row.newInterface.title": "เลย์เอาต์ใหม่",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "ใหม่",
"เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่", "settings.general.row.newInterface.description":
"ใช้แท็บใหม่และเลย์เอาต์หน้าแรก คุณสามารถสลับระหว่างเลย์เอาต์ได้ในช่วงเวลาจำกัด",
"settings.general.row.newInterfaceNotice.title": "ขณะนี้คุณกำลังใช้เลย์เอาต์ใหม่",
"settings.general.row.newInterfaceNotice.description": "เลย์เอาต์ก่อนหน้าไม่พร้อมใช้งานอีกต่อไป",
"settings.general.row.newInterfaceNotice.dismiss": "ปิด",
"settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม", "settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม",
"settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม", "settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",

View file

@ -813,9 +813,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet", "settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar", "settings.general.row.newInterface.title": "Yeni düzen",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Yeni",
"Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir", "settings.general.row.newInterface.description":
"Yeni sekmeleri ve ana sayfa düzenini kullanın. Sınırlı bir süre boyunca düzenler arasında geçiş yapabilirsiniz.",
"settings.general.row.newInterfaceNotice.title": "Artık yeni düzeni kullanıyorsunuz",
"settings.general.row.newInterfaceNotice.description": "Önceki düzen artık kullanılamıyor",
"settings.general.row.newInterfaceNotice.dismiss": "Kapat",
"settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır", "settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver", "İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver",

View file

@ -898,9 +898,13 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування", "settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі", "Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн", "settings.general.row.newInterface.title": "Новий макет",
"settings.general.row.newLayoutDesigns.description": "settings.general.row.newInterface.badge": "Нове",
"Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії", "settings.general.row.newInterface.description":
"Використовуйте нові вкладки та макет головної сторінки. Протягом обмеженого часу можна перемикатися між макетами.",
"settings.general.row.newInterfaceNotice.title": "Тепер ви використовуєте новий макет",
"settings.general.row.newInterfaceNotice.description": "Попередній макет більше недоступний",
"settings.general.row.newInterfaceNotice.dismiss": "Відхилити",
"settings.general.row.pinchZoom.title": "Масштабування щипком", "settings.general.row.pinchZoom.title": "Масштабування щипком",
"settings.general.row.pinchZoom.description": "settings.general.row.pinchZoom.description":
"Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl", "Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl",

View file

@ -793,8 +793,12 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
"settings.general.row.newLayoutDesigns.title": "新版布局和设计", "settings.general.row.newInterface.title": "新布局",
"settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面", "settings.general.row.newInterface.badge": "新",
"settings.general.row.newInterface.description": "使用新的标签页和主页布局。在有限时间内可在不同布局之间切换。",
"settings.general.row.newInterfaceNotice.title": "你现在使用的是新布局",
"settings.general.row.newInterfaceNotice.description": "之前的布局不再可用",
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
"settings.general.row.pinchZoom.title": "双指缩放", "settings.general.row.pinchZoom.title": "双指缩放",
"settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放", "settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放",
"settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.title": "使用原生 Wayland",

View file

@ -789,8 +789,12 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
"settings.general.row.newLayoutDesigns.title": "新版面與設計", "settings.general.row.newInterface.title": "新版面",
"settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面", "settings.general.row.newInterface.badge": "新",
"settings.general.row.newInterface.description": "使用新的分頁和首頁版面。在限定時間內可切換版面。",
"settings.general.row.newInterfaceNotice.title": "你現在使用的是新版面",
"settings.general.row.newInterfaceNotice.description": "先前的版面已無法使用",
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
"settings.general.row.pinchZoom.title": "雙指縮放", "settings.general.row.pinchZoom.title": "雙指縮放",
"settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放", "settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放",
"settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.title": "使用原生 Wayland",

View file

@ -3,6 +3,7 @@ export { useLayout } from "./context/layout"
export { useServerSDK } from "./context/server-sdk" export { useServerSDK } from "./context/server-sdk"
export { useServerSync } from "./context/server-sync" export { useServerSync } from "./context/server-sync"
export { useServer } from "./context/server" export { useServer } from "./context/server"
export { useSettings } from "./context/settings"
export { useTabs } from "./context/tabs" export { useTabs } from "./context/tabs"
export { useProviders } from "./hooks/use-providers" export { useProviders } from "./hooks/use-providers"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"

View file

@ -35,7 +35,7 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useSettingsDialog } from "@/components/settings-dialog" import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server" import { ServerConnection, serverName, useServer } from "@/context/server"
@ -269,7 +269,7 @@ export function NewHome() {
const command = useCommand() const command = useCommand()
const notification = useNotification() const notification = useNotification()
const marked = useMarked() const marked = useMarked()
const openSettings = useSettingsDialog() const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({ const [state, setState] = createStore({
search: "", search: "",
@ -795,7 +795,7 @@ function HomeUtilityNav(props: {
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
return ( return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}> <div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<button <button
type="button" type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`} class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}

View file

@ -6,13 +6,11 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast" import { setV2Toast, ToastRegion } from "@/utils/toast"
import { useSettingsCommand } from "@/components/settings-dialog"
export default function NewLayout(props: ParentProps) { export default function NewLayout(props: ParentProps) {
const platform = usePlatform() const platform = usePlatform()
const navigate = useNavigate() const navigate = useNavigate()
setNavigate(navigate) setNavigate(navigate)
useSettingsCommand()
createEffect(() => setV2Toast(true)) createEffect(() => setV2Toast(true))

View file

@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
const global = useGlobal() const global = useGlobal()
const notification = useNotification() const notification = useNotification()
const permission = usePermission() const permission = usePermission()
const permissionState = createMemo(() => permission.ensureServerState(server()))
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const sync = createMemo(() => { const sync = createMemo(() => {
const conn = connection() const conn = connection()
@ -23,7 +24,7 @@ export function useSessionTabAvatarState(
if (!serverSync) return false if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false }) const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory()) return !permissionState().autoResponds(item, directory())
}) })
}) })
const hasQuestions = createMemo(() => { const hasQuestions = createMemo(() => {

View file

@ -28,7 +28,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar" import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSettingsDialog } from "@/components/settings-dialog" import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence" import createPresence from "solid-presence"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@ -54,6 +54,7 @@ export default function NewSessionPage() {
const command = useCommand() const command = useCommand()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers") const openProviderSettings = useSettingsDialog("providers")
useSettingsCommand()
const route = useSessionKey() const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal() const local = useLocal()

View file

@ -47,7 +47,6 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider, usePrompt } from "@/context/prompt" import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
@ -58,6 +57,7 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal" import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import { setCursorPosition } from "@/components/prompt-input/editor-dom" import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history" import { promptLength } from "@/components/prompt-input/history"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
@ -155,13 +155,25 @@ export function SessionPage() {
// workspace-scoped state (terminal, directory providers) lives below. // workspace-scoped state (terminal, directory providers) lives below.
export function TargetSessionRouteContent() { export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const serverSync = useServerSync()
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
return ( return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded> // Settings must keep the target-server SDK, sync, and models context and remain registered
<ResolvedTargetSessionRoute /> // when session content falls back to the route error boundary.
</SessionRouteErrorBoundary> <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</TargetServerScopedProviders>
) )
} }
function TargetSessionSettingsCommand() {
useSettingsCommand()
return null
}
export function SessionRouteErrorBoundary( export function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) { ) {
@ -250,19 +262,17 @@ function ResolvedTargetSessionRoute() {
}) })
return ( return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}> // Non-keyed: closes only while the target's directory is unknown (uncached
{/* Non-keyed: closes only while the target's directory is unknown (uncached // lineage mid-resolution), which tears down the workspace subtree including
lineage mid-resolution), which tears down the workspace subtree including // the terminal. Same-workspace tab switches keep it open because warm
the terminal. Same-workspace tab switches keep it open because warm // targets resolve synchronously from the sync cache.
targets resolve synchronously from the sync cache. */} <Show when={directory()}>
<Show when={directory()}> <SDKProvider directory={targetDirectory}>
<SDKProvider directory={targetDirectory}> <DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}> <TargetSessionPage />
<TargetSessionPage /> </DirectoryDataProvider>
</DirectoryDataProvider> </SDKProvider>
</SDKProvider> </Show>
</Show>
</TargetServerScopedProviders>
) )
} }
@ -283,10 +293,10 @@ function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) { ) {
return ( return (
<PermissionProvider directory={props.directory}> <>
<MarkSessionNotificationsViewed sessionID={props.sessionID} /> <MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider> </>
) )
} }

View file

@ -757,7 +757,7 @@ function SessionFileViewV2(props: { tab: string }) {
commentsUi.onLineNumberSelectionEnd(range) commentsUi.onLineNumberSelectionEnd(range)
}} }}
search={search} search={search}
class="select-text" class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]"
media={{ media={{
mode: "auto", mode: "auto",
path: path(), path: path(),

View file

@ -28,7 +28,6 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
export function TerminalPanelV2(props: { stacked?: boolean } = {}) { export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
const delays = [120, 240]
const layout = useLayout() const layout = useLayout()
const terminal = useTerminal() const terminal = useTerminal()
const sdk = useSDK() const sdk = useSDK()
@ -46,6 +45,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
let tabList: HTMLDivElement | undefined let tabList: HTMLDivElement | undefined
onCleanup(() => terminal.cancelFocus())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
autoCreated: false, autoCreated: false,
recovered: {} as Record<string, boolean>, recovered: {} as Record<string, boolean>,
@ -94,36 +95,12 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
), ),
) )
const focus = (id: string) => {
focusTerminalById(id)
const frame = requestAnimationFrame(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
})
const timers = delays.map((ms) =>
window.setTimeout(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
}, ms),
)
return () => {
cancelAnimationFrame(frame)
for (const timer of timers) clearTimeout(timer)
}
}
createEffect( createEffect(
on( on(
() => [opened(), terminal.active()] as const, () => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
([next, id]) => { ([next, id, requested]) => {
if (!next || !id) return if (!next || !id || !requested) return
const stop = focus(id) focusTerminalById(id)
onCleanup(stop)
}, },
), ),
) )
@ -285,7 +262,15 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
onChange={(id) => terminal.open(id)} onChange={(id) => terminal.open(id)}
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"} class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
> >
<Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}> <Tabs.List
ref={tabList}
class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
>
<For each={all()}> <For each={all()}>
{(pty, index) => ( {(pty, index) => (
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} /> <SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
@ -304,7 +289,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
icon="plus-small" icon="plus-small"
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={terminal.new} onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")} aria-label={language.t("command.terminal.new")}
/> />
</TooltipKeybind> </TooltipKeybind>
@ -326,7 +311,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
icon="plus-small" icon="plus-small"
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={terminal.new} onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")} aria-label={language.t("command.terminal.new")}
/> />
</TooltipV2> </TooltipV2>
@ -344,7 +329,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
<div id={`terminal-wrapper-${id}`} class="absolute inset-0"> <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal <Terminal
pty={pty()} pty={pty()}
autoFocus={opened()} autoFocus={terminal.focusRequested(id)}
onAutoFocus={() => terminal.consumeFocus(id)}
class="!px-[14px]" class="!px-[14px]"
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update} onCleanup={ops.update}

View file

@ -38,6 +38,8 @@ export function TerminalPanel() {
const close = () => view().terminal.close() const close = () => view().terminal.close()
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
onCleanup(() => terminal.cancelFocus())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
autoCreated: false, autoCreated: false,
activeDraggable: undefined as string | undefined, activeDraggable: undefined as string | undefined,
@ -285,7 +287,7 @@ export function TerminalPanel() {
icon="plus-small" icon="plus-small"
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={terminal.new} onClick={() => terminal.new()}
aria-label={language.t("command.terminal.new")} aria-label={language.t("command.terminal.new")}
/> />
</TooltipKeybind> </TooltipKeybind>
@ -303,6 +305,7 @@ export function TerminalPanel() {
<Terminal <Terminal
pty={pty()} pty={pty()}
autoFocus={opened()} autoFocus={opened()}
onAutoFocus={() => terminal.consumeFocus(id)}
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update} onCleanup={ops.update}
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}

View file

@ -72,6 +72,7 @@ import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title" import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure" import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection" import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items" import { filterVirtualIndexes } from "./virtual-items"
@ -408,6 +409,7 @@ export function MessageTimeline(props: {
return timelineRows().length return timelineRows().length
}, },
getScrollElement: () => listRoot() ?? null, getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements, initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize, estimateSize: () => timelineFallbackItemSize,

View file

@ -0,0 +1,199 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const child = document.createElement("div")
const sibling = document.createElement("div")
route.append(viewport)
viewport.append(child)
expect(mutationNodesContainElement([viewport], viewport)).toBe(true)
expect(mutationNodesContainElement([route], viewport)).toBe(true)
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const unrelated = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
cleanup?.()
route.remove()
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 20,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: number[] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => {
calls.push(offset)
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(1)
expect(instance.scrollOffset).toBe(0)
instance.scrollOffset = 79_400
await new Promise((resolve) => setTimeout(resolve, 25))
await frames(3)
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
cleanup?.()
route.remove()
})
test.each([
{ name: "LTR", isRtl: false, expected: 240 },
{ name: "RTL", isRtl: true, expected: -240 },
])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
viewport.scrollLeft = 240
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: true,
isRtl,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[expected, false]])
cleanup?.()
route.remove()
})
test("cleanup suppresses an already queued delegated offset callback", async () => {
const viewport = document.createElement("div")
document.body.append(viewport)
viewport.scrollTop = 100
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 10,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) =>
calls.push([offset, isScrolling]),
)
viewport.dispatchEvent(new Event("scroll"))
cleanup?.()
await new Promise((resolve) => setTimeout(resolve, 25))
expect(calls).toEqual([[100, true]])
viewport.remove()
})
test("cleanup cancels reconnect checks and delegated offset observation", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 50,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: number[] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset))
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
cleanup?.()
instance.scrollOffset = 100
viewport.dispatchEvent(new Event("scroll"))
await frames(4)
expect(calls).toEqual([])
route.remove()
})
async function frames(count: number) {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}

View file

@ -0,0 +1,73 @@
import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
instance: Virtualizer<TScrollElement, TItemElement>,
callback: (offset: number, isScrolling: boolean) => void,
) {
let active = true
const deliver = (offset: number, isScrolling: boolean) => {
if (!active) return
callback(offset, isScrolling)
}
const cleanupOffset = observeElementOffset(instance, deliver)
const element = instance.scrollElement
const targetWindow = instance.targetWindow
const root = element?.closest("main") ?? element?.ownerDocument.body
if (!element || !targetWindow || !root)
return () => {
active = false
cleanupOffset?.()
}
let removed = false
let frame: number | undefined
const clearCheck = () => {
if (frame === undefined) return
targetWindow.cancelAnimationFrame(frame)
frame = undefined
}
const startCheck = () => {
clearCheck()
const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay
let framesAfterDeadline = 0
const check = (time: number) => {
frame = undefined
if (element.isConnected) {
const offset = instance.options.horizontal
? element.scrollLeft * (instance.options.isRtl ? -1 : 1)
: element.scrollTop
if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false)
}
if (time >= deadline) framesAfterDeadline += 1
if (framesAfterDeadline >= 2) return
frame = targetWindow.requestAnimationFrame(check)
}
frame = targetWindow.requestAnimationFrame(check)
}
const observer = new targetWindow.MutationObserver((records) => {
if (!active) return
records.forEach((record) => {
if (record.target === element || element.contains(record.target)) return
if (mutationNodesContainElement(record.removedNodes, element)) {
removed = true
clearCheck()
}
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
removed = false
startCheck()
})
})
// Session routes are replaced below persistent main; body is the fallback for isolated hosts.
observer.observe(root, { childList: true, subtree: true })
return () => {
active = false
observer.disconnect()
clearCheck()
cleanupOffset?.()
}
}
export function mutationNodesContainElement(nodes: Iterable<Node>, element: Element) {
return [...nodes].some((node) => node === element || node.contains(element))
}

View file

@ -264,7 +264,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
const openTerminal = () => { const openTerminal = () => {
if (terminal.all().length > 0) terminal.new() if (terminal.all().length > 0) terminal.new({ focus: true })
if (terminal.all().length === 0) terminal.requestFocus()
view().terminal.open() view().terminal.open()
} }
@ -498,7 +499,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.terminal.toggle"), title: language.t("command.terminal.toggle"),
keybind: "ctrl+`", keybind: "ctrl+`",
slash: "terminal", slash: "terminal",
onSelect: () => view().terminal.toggle(), onSelect: () => {
if (view().terminal.opened()) {
terminal.cancelFocus()
view().terminal.close()
return
}
terminal.requestFocus(terminal.active())
view().terminal.open()
},
}), }),
viewCommand({ viewCommand({
id: "review.toggle", id: "review.toggle",

View file

@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
export function createReviewPanelV2State() { export function createReviewPanelV2State() {
const [store, setStore] = persisted( const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"), Persist.global("review-panel-v2"),
createStore({ createStore({
sidebarOpened: true, sidebarOpened: true,
@ -24,6 +24,7 @@ export function createReviewPanelV2State() {
return { return {
sidebarOpened: () => store.sidebarOpened, sidebarOpened: () => store.sidebarOpened,
sidebarWidth: () => store.sidebarWidth, sidebarWidth: () => store.sidebarWidth,
sidebarTransition: ready,
filter, filter,
setFilter, setFilter,
expandMode: () => store.expandMode, expandMode: () => store.expandMode,

View file

@ -199,6 +199,7 @@ function ReviewPanelV2Sidebar(props: {
return ( return (
<SessionReviewV2Sidebar <SessionReviewV2Sidebar
open={props.state.sidebarOpened()} open={props.state.sidebarOpened()}
transition={props.state.sidebarTransition()}
title={props.title} title={props.title}
stats={<DiffChanges changes={props.diffs()} />} stats={<DiffChanges changes={props.diffs()} />}
filter={props.state.filter()} filter={props.state.filter()}

View file

@ -19,6 +19,7 @@ const emptyFiles: string[] = []
export type SessionFileBrowserState = { export type SessionFileBrowserState = {
sidebarOpened: () => boolean sidebarOpened: () => boolean
sidebarWidth: () => number sidebarWidth: () => number
sidebarTransition: () => boolean
resizeSidebar: (width: number) => void resizeSidebar: (width: number) => void
toggleSidebar: () => void toggleSidebar: () => void
} }
@ -96,6 +97,7 @@ export function SessionFileBrowserTab(props: {
sidebar={ sidebar={
<SessionReviewV2Sidebar <SessionReviewV2Sidebar
open={sidebarOpened()} open={sidebarOpened()}
transition={props.state.sidebarTransition()}
title={<span class="truncate">{title()}</span>} title={<span class="truncate">{title()}</span>}
filter={filter()} filter={filter()}
onFilterChange={setFilter} onFilterChange={setFilter}

View file

@ -0,0 +1,65 @@
import { beforeAll, expect, mock, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State
let read: ((value: string | null) => void) | undefined
const storage: AsyncStorage = {
getItem: () => new Promise((resolve) => (read = resolve)),
setItem: async () => undefined,
removeItem: async () => undefined,
clear: async () => undefined,
key: async () => null,
getLength: async () => 0,
length: Promise.resolve(0),
}
beforeAll(async () => {
mock.module("@opencode-ai/session-ui/v2/session-review-v2", () => ({
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT: 240,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN: 200,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480,
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
}))
createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State
})
test("enables sidebar motion only after custom width hydration", async () => {
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const state = createReviewPanelV2State()
const transition =
"sidebarTransition" in state && typeof state.sidebarTransition === "function"
? (state.sidebarTransition as () => boolean)
: undefined
try {
expect(transition).toBeFunction()
expect(transition?.()).toBeFalse()
expect(state.sidebarWidth()).toBe(240)
} catch (error) {
dispose()
reject(error)
return
}
createEffect(() => {
if (!transition?.()) return
try {
expect(state.sidebarWidth()).toBe(360)
dispose()
resolve()
} catch (error) {
dispose()
reject(error)
}
})
read?.(JSON.stringify({ sidebarOpened: true, sidebarWidth: 360, expandMode: "collapse" }))
})
})
})

View file

@ -18,7 +18,8 @@ export async function statsProxy(evt: APIEvent) {
if ( if (
targetUrl.pathname.startsWith(`${dataPath}/_build/`) || targetUrl.pathname.startsWith(`${dataPath}/_build/`) ||
targetUrl.pathname === `${dataPath}/banner.jpg` || targetUrl.pathname === `${dataPath}/banner.jpg` ||
targetUrl.pathname === `${dataPath}/banner.png` targetUrl.pathname === `${dataPath}/banner.png` ||
targetUrl.pathname === `${dataPath}/sitemap.xml`
) { ) {
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length) targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
} }
@ -97,7 +98,8 @@ function isDataBypassPath(pathname: string) {
pathname.startsWith(`${dataPath}/api/`) || pathname.startsWith(`${dataPath}/api/`) ||
pathname.startsWith(`${dataPath}/_server`) || pathname.startsWith(`${dataPath}/_server`) ||
pathname === `${dataPath}/banner.jpg` || pathname === `${dataPath}/banner.jpg` ||
pathname === `${dataPath}/banner.png` pathname === `${dataPath}/banner.png` ||
pathname === `${dataPath}/sitemap.xml`
) )
} }

View file

@ -19,7 +19,12 @@ import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown" import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding" import {
finishFirstLaunchOnboarding,
initializeOldLayoutEligibility,
isFirstLaunchOnboardingPending,
isOldLayoutEligible,
} from "./onboarding"
import { import {
getDefaultServerUrl, getDefaultServerUrl,
preferAppEnv, preferAppEnv,
@ -137,6 +142,7 @@ const main = Effect.gen(function* () {
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
) )
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
initializeOldLayoutEligibility(app.getPath("userData"))
logger = initLogging() logger = initLogging()
initCrashReporter() initCrashReporter()
@ -280,6 +286,7 @@ const main = Effect.gen(function* () {
setDefaultServerUrl: (url) => setDefaultServerUrl(url), setDefaultServerUrl: (url) => setDefaultServerUrl(url),
isFirstLaunchOnboardingPending, isFirstLaunchOnboardingPending,
finishFirstLaunchOnboarding, finishFirstLaunchOnboarding,
isOldLayoutEligible,
getDisplayBackend: async () => null, getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined, setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown), parseMarkdown: async (markdown) => parseMarkdown(markdown),

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test"
import { hasExistingAppState } from "./install-state"
const file = (name: string) => ({ name, isDirectory: () => false })
const directory = (name: string) => ({ name, isDirectory: () => true })
describe("hasExistingAppState", () => {
test("ignores files Electron may create on a fresh install", () => {
expect(hasExistingAppState([])).toBe(false)
expect(hasExistingAppState([file("Local State"), directory("Crashpad")])).toBe(false)
})
test("recognizes state written by an earlier OpenCode launch", () => {
expect(hasExistingAppState([file("opencode.settings")])).toBe(true)
expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true)
expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true)
expect(hasExistingAppState([directory("opencode")])).toBe(true)
})
})

View file

@ -0,0 +1,8 @@
export function hasExistingAppState(entries: Array<{ name: string; isDirectory: () => boolean }>) {
return entries.some((entry) => {
if (entry.name === "opencode.settings") return true
if (entry.name.endsWith(".dat")) return true
if (/^window-state-.+\.json$/.test(entry.name)) return true
return entry.isDirectory() && entry.name === "opencode"
})
}

View file

@ -29,6 +29,7 @@ type Deps = {
setDefaultServerUrl: (url: string | null) => Promise<void> | void setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
isOldLayoutEligible: () => Promise<boolean> | boolean
getDisplayBackend: () => Promise<string | null> getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string parseMarkdown: (markdown: string) => Promise<string> | string
@ -56,6 +57,7 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
deps.finishFirstLaunchOnboarding(createDefaultProject), deps.finishFirstLaunchOnboarding(createDefaultProject),
) )
ipcMain.handle("is-old-layout-eligible", () => deps.isOldLayoutEligible())
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend), deps.setDisplayBackend(backend),

View file

@ -1,11 +1,28 @@
import { existsSync, readdirSync } from "node:fs"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import { join } from "node:path" import { join } from "node:path"
import { app } from "electron" import { app } from "electron"
import { getStore } from "./store" import { getStore } from "./store"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys" import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from "./store-keys"
import { write as writeLog } from "./logging" import { write as writeLog } from "./logging"
import { hasExistingAppState } from "./install-state"
const DEFAULT_PROJECT_DIR = "New OpenCode Project" const DEFAULT_PROJECT_DIR = "Default Project"
export function initializeOldLayoutEligibility(userDataPath: string) {
const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []
const store = getStore()
const current = store.get(OLD_LAYOUT_ELIGIBLE_KEY)
if (typeof current === "boolean") return current
const eligible = hasExistingAppState(entries)
store.set(OLD_LAYOUT_ELIGIBLE_KEY, eligible)
return eligible
}
export function isOldLayoutEligible() {
return getStore().get(OLD_LAYOUT_ELIGIBLE_KEY) === true
}
export function isFirstLaunchOnboardingPending() { export function isFirstLaunchOnboardingPending() {
const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true

View file

@ -1,6 +1,7 @@
export const SETTINGS_STORE = "opencode.settings" export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete" export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible"
export const WSL_SERVERS_KEY = "wslServers" export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const WINDOW_IDS_KEY = "windowIds" export const WINDOW_IDS_KEY = "windowIds"

View file

@ -36,6 +36,7 @@ protocol.registerSchemesAsPrivileged([
secure: true, secure: true,
standard: true, standard: true,
supportFetchAPI: true, supportFetchAPI: true,
stream: true,
}, },
}, },
]) ])
@ -266,7 +267,10 @@ export function registerRendererProtocol() {
} }
try { try {
const response = await net.fetch(pathToFileURL(file).toString()) const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), {
headers: range ? { range } : undefined,
})
if (response.status >= 400) { if (response.status >= 400) {
writeLog( writeLog(
"protocol", "protocol",

View file

@ -62,6 +62,7 @@ const api: ElectronAPI = {
isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"), isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"),
finishFirstLaunchOnboarding: (createDefaultProject) => finishFirstLaunchOnboarding: (createDefaultProject) =>
ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject), ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject),
isOldLayoutEligible: () => ipcRenderer.invoke("is-old-layout-eligible"),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown), parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),

View file

@ -52,6 +52,7 @@ export type ElectronAPI = {
setDefaultServerUrl: (url: string | null) => Promise<void> setDefaultServerUrl: (url: string | null) => Promise<void>
isFirstLaunchOnboardingPending: () => Promise<boolean> isFirstLaunchOnboardingPending: () => Promise<boolean>
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
isOldLayoutEligible: () => Promise<boolean>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null> getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void> setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string> parseMarkdownCommand: (markdown: string) => Promise<string>

View file

@ -1,20 +1,9 @@
import { import { ServerConnection, useServer, useSettings, useTabs } from "@opencode-ai/app"
ServerConnection, import { onMount } from "solid-js"
useLayout,
useProviders,
useServer,
useServerSDK,
useServerSync,
useTabs,
} from "@opencode-ai/app"
import { onMount, startTransition } from "solid-js"
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) { export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
const server = useServer() const server = useServer()
const serverSDK = useServerSDK() const settings = useSettings()
const serverSync = useServerSync()
const layout = useLayout()
const providers = useProviders()
const tabs = useTabs() const tabs = useTabs()
onMount(() => { onMount(() => {
@ -24,39 +13,26 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
async function runFirstLaunchOnboarding() { async function runFirstLaunchOnboarding() {
try { try {
await Promise.all( await Promise.all(
[server.ready.promise, layout.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map( [server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()),
(p) => p ?? Promise.resolve(),
),
) )
const existingInstall = await window.api.isOldLayoutEligible()
settings.general.setOldLayoutEligible(existingInstall)
if (!server.isLocal()) return if (!server.isLocal()) return
const pending = await window.api.isFirstLaunchOnboardingPending() const pending = await window.api.isFirstLaunchOnboardingPending()
if (!pending) return if (!pending) return
const sessions = await serverSDK()
.client.session.list()
.then((x) => x.data ?? [])
.catch(() => undefined)
const connectedProviders = providers.connected()
const paidProviders = providers.paid()
const persistedProjects = layout.projects.list()
const shouldTrigger = const shouldTrigger =
!existingInstall &&
props.initialUrl === "/" && props.initialUrl === "/" &&
sessions?.length === 0 &&
paidProviders.length === 0 &&
persistedProjects.length === 0 &&
tabs.store.length === 0 && tabs.store.length === 0 &&
server.list.every(ServerConnection.builtin) server.list.every(ServerConnection.builtin)
console.info("[desktop-onboarding] first launch onboarding evaluated", { console.info("[desktop-onboarding] first launch onboarding evaluated", {
pending, pending,
shouldTrigger, shouldTrigger,
existingInstall,
initialUrl: props.initialUrl, initialUrl: props.initialUrl,
sessions: sessions?.length,
connectedProviders: connectedProviders.length,
paidProviders: paidProviders.length,
serverProjects: serverSync().data.project.length,
persistedProjects: persistedProjects.length,
tabs: tabs.store.length, tabs: tabs.store.length,
servers: server.list.map(ServerConnection.key), servers: server.list.map(ServerConnection.key),
}) })
@ -67,9 +43,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
console.info("[desktop-onboarding] starting first launch draft", { directory }) console.info("[desktop-onboarding] starting first launch draft", { directory })
server.projects.open(directory) server.projects.open(directory)
server.projects.touch(directory) server.projects.touch(directory)
await startTransition(() => { tabs.select(await tabs.newDraft({ server: server.key, directory }))
tabs.newDraft({ server: server.key, directory })
})
} catch (error) { } catch (error) {
console.error("[desktop-onboarding] first launch onboarding failed", error) console.error("[desktop-onboarding] first launch onboarding failed", error)
} }

View file

@ -133,10 +133,6 @@
align-items: flex-end; align-items: flex-end;
} }
[data-slot="user-message-attachments"] + [data-slot="user-message-body"] {
margin-top: 8px;
}
[data-slot="user-message-text"] { [data-slot="user-message-text"] {
display: inline-block; display: inline-block;
white-space: pre-wrap; white-space: pre-wrap;
@ -1302,10 +1298,63 @@
} }
} }
body[data-new-layout] [data-component="user-message"] {
font-weight: 440;
[data-slot="user-message-text"] {
background: var(--v2-background-bg-layer-01);
}
[data-slot="user-message-comments"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: min(320px, 82%);
margin-left: auto;
&[data-bounded] {
width: 318px;
max-width: 100%;
margin-top: 8px;
}
[data-component="tooltip-v2-trigger"] {
width: 100%;
}
[data-component="button-v2"] {
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-weight: 530;
font-variation-settings:
"wght" 530,
"slnt" 0;
}
}
[data-slot="user-message-body"] + [data-slot="user-message-attachments"],
[data-slot="user-message-comments"] + [data-slot="user-message-attachments"] {
margin-top: 8px;
}
[data-slot="user-message-text"][data-comments] {
width: 342px;
padding-bottom: 12px;
}
}
[data-color-scheme="light"] body[data-new-layout] [data-component="user-message"] [data-slot="user-message-text"] {
background: var(--v2-background-bg-layer-02);
}
body:not([data-new-layout]) { body:not([data-new-layout]) {
[data-component="user-message"] { [data-component="user-message"] {
color: var(--text-strong); color: var(--text-strong);
[data-slot="user-message-attachments"] + [data-slot="user-message-body"] {
margin-top: 8px;
}
[data-slot="user-message-attachment"] { [data-slot="user-message-attachment"] {
background: var(--surface-weak); background: var(--surface-weak);
border: 1px solid var(--border-weak-base); border: 1px solid var(--border-weak-base);

View file

@ -52,6 +52,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
@ -1171,6 +1172,34 @@ export function ContextToolGroup(props: {
) )
} }
function UserMessageComments(props: { comments: UserMessageComment[]; bounded: boolean }) {
const i18n = useI18n()
const [state, setState] = createStore({ expanded: false })
const comments = createMemo(() => (props.bounded && !state.expanded ? props.comments.slice(0, 5) : props.comments))
return (
<div data-slot="user-message-comments" data-bounded={props.bounded ? "true" : undefined}>
<For each={comments()}>
{(comment) => (
<CommentCardV2
comment={comment.comment}
path={comment.path}
selection={comment.selection}
title={comment.comment}
tooltip
wide
/>
)}
</For>
<Show when={props.bounded && props.comments.length > 5 && !state.expanded}>
<ButtonV2 size="small" variant="ghost-muted" onClick={() => setState("expanded", true)}>
{i18n.t("ui.common.showMore")}
</ButtonV2>
</Show>
</div>
)
}
export function UserMessageDisplay(props: { export function UserMessageDisplay(props: {
message: UserMessage message: UserMessage
parts: PartType[] parts: PartType[]
@ -1254,107 +1283,114 @@ export function UserMessageDisplay(props: {
.finally(() => setState("busy", false)) .finally(() => setState("busy", false))
} }
const renderAttachments = () => (
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
{(file) => {
const type = kind(file)
const name = file.filename ?? i18n.t("ui.message.attachment.alt")
return (
<Show
when={newLayout() && type === "file"}
fallback={
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
>
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
}
>
<AttachmentCardV2
title={getFilename(name)}
hover={name}
clickable={!!props.actions?.openAttachment}
onClick={() => props.actions?.openAttachment?.(file)}
>
{typeLabel(name, file.mime)}
</AttachmentCardV2>
</Show>
)
}}
</For>
</div>
</Show>
)
return ( return (
<div data-component="user-message" data-timeline-part-id={textPart()?.id}> <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
<Show when={attachments().length > 0 || messageComments().length > 0}> <Show when={!props.useV2Actions}>{renderAttachments()}</Show>
<div data-slot="user-message-attachments"> <Show
<For each={messageComments()}> when={text()}
{(comment) => ( fallback={
<CommentCardV2 <Show when={messageComments().length > 0}>
comment={comment.comment} <UserMessageComments comments={messageComments()} bounded={false} />
path={comment.path} </Show>
selection={comment.selection} }
title={comment.comment} >
/> <div data-slot="user-message-body">
)} <div data-slot="user-message-text" data-comments={messageComments().length > 0 ? "true" : undefined}>
</For> <HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<For each={attachments()}> <Show when={messageComments().length > 0}>
{(file) => { <UserMessageComments comments={messageComments()} bounded />
const type = kind(file) </Show>
const name = file.filename ?? i18n.t("ui.message.attachment.alt") </div>
return (
<Show
when={newLayout() && type === "file"}
fallback={
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
>
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
}
>
<AttachmentCardV2
title={getFilename(name)}
hover={name}
clickable={!!props.actions?.openAttachment}
onClick={() => props.actions?.openAttachment?.(file)}
>
{typeLabel(name, file.mime)}
</AttachmentCardV2>
</Show>
)
}}
</For>
</div> </div>
</Show> </Show>
<Show when={text()}> <Show when={props.useV2Actions}>{renderAttachments()}</Show>
<> <Show when={text() || (props.useV2Actions && messageComments().length > 0)}>
<div data-slot="user-message-body"> <div data-slot="user-message-copy-wrapper">
<div data-slot="user-message-text"> <Show when={metaHead() || metaTail()}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} /> <span data-slot="user-message-meta-wrap">
</div> <Show when={metaHead()}>
</div> <span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
<div data-slot="user-message-copy-wrapper"> {metaHead()}
<Show when={metaHead() || metaTail()}> </span>
<span data-slot="user-message-meta-wrap"> </Show>
<Show when={metaHead()}> <Show when={metaHead() && metaTail()}>
<span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default"> <span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default">
{metaHead()} {"\u00A0\u00B7\u00A0"}
</span> </span>
</Show> </Show>
<Show when={metaHead() && metaTail()}> <Show when={metaTail()}>
<span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default"> <span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default">
{"\u00A0\u00B7\u00A0"} {metaTail()}
</span> </span>
</Show> </Show>
<Show when={metaTail()}> </span>
<span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default"> </Show>
{metaTail()} <Show when={props.actions?.revert}>
</span> <MessageActionButton
</Show> icon="reset"
</span> label={i18n.t("ui.message.revertMessage")}
</Show> useV2={props.useV2Actions}
<Show when={props.actions?.revert}> disabled={!!busy()}
<MessageActionButton onMouseDown={(event) => event.preventDefault()}
icon="reset" onClick={(event) => {
label={i18n.t("ui.message.revertMessage")} event.stopPropagation()
useV2={props.useV2Actions} revert()
disabled={!!busy()} }}
onMouseDown={(event) => event.preventDefault()} aria-label={i18n.t("ui.message.revertMessage")}
onClick={(event) => { />
event.stopPropagation() </Show>
revert() <Show when={text()}>
}}
aria-label={i18n.t("ui.message.revertMessage")}
/>
</Show>
<MessageActionButton <MessageActionButton
icon={copied() ? "check" : "copy"} icon={copied() ? "check" : "copy"}
label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
@ -1366,8 +1402,8 @@ export function UserMessageDisplay(props: {
}} }}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/> />
</div> </Show>
</> </div>
</Show> </Show>
</div> </div>
) )

View file

@ -15,6 +15,13 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
} }
const unsafeCSS = ` const unsafeCSS = `
:host {
--diffs-bg: var(
--opencode-diffs-bg,
light-dark(var(--diffs-light-bg, #fff), var(--diffs-dark-bg, #000))
);
}
[data-diff], [data-diff],
[data-file] { [data-file] {
/* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */ /* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
@ -129,7 +136,7 @@ const unsafeCSS = `
height: 24px; height: 24px;
} }
[data-column-number] { [data-column-number] {
background-color: var(--background-stronger); background-color: var(--diffs-bg);
cursor: default !important; cursor: default !important;
} }

View file

@ -12,6 +12,16 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base); box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
cursor: default; cursor: default;
&[data-wide] {
width: 100%;
min-width: 0;
max-width: none;
}
&[data-surface="base"] {
background: var(--v2-background-bg-base);
}
&[data-active] { &[data-active] {
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong); box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
} }
@ -32,6 +42,8 @@
} }
[data-slot="attachment-card-v2-title"] { [data-slot="attachment-card-v2-title"] {
width: 100%;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

View file

@ -6,8 +6,11 @@ export function AttachmentCardV2(props: {
title: string title: string
active?: boolean active?: boolean
clickable?: boolean clickable?: boolean
wide?: boolean
surface?: "base"
/** native title attribute */ /** native title attribute */
hover?: string hover?: string
titleRef?: (element: HTMLSpanElement) => void
onClick?: () => void onClick?: () => void
children: JSX.Element children: JSX.Element
}) { }) {
@ -16,10 +19,14 @@ export function AttachmentCardV2(props: {
data-component="attachment-card-v2" data-component="attachment-card-v2"
data-active={props.active ? "true" : undefined} data-active={props.active ? "true" : undefined}
data-clickable={props.clickable ? "true" : undefined} data-clickable={props.clickable ? "true" : undefined}
data-wide={props.wide ? "true" : undefined}
data-surface={props.surface}
title={props.hover} title={props.hover}
onClick={() => props.onClick?.()} onClick={() => props.onClick?.()}
> >
<span data-slot="attachment-card-v2-title">{props.title}</span> <span ref={(element) => props.titleRef?.(element)} data-slot="attachment-card-v2-title">
{props.title}
</span>
<span data-slot="attachment-card-v2-subtitle">{props.children}</span> <span data-slot="attachment-card-v2-subtitle">{props.children}</span>
</div> </div>
) )

View file

@ -1,6 +1,7 @@
import { Show } from "solid-js" import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getFilenameTruncated } from "@opencode-ai/core/util/path" import { getFilenameTruncated } from "@opencode-ai/core/util/path"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { AttachmentCardV2 } from "./attachment-card-v2" import { AttachmentCardV2 } from "./attachment-card-v2"
export function CommentCardV2(props: { export function CommentCardV2(props: {
@ -9,19 +10,55 @@ export function CommentCardV2(props: {
selection?: { startLine: number; endLine: number } selection?: { startLine: number; endLine: number }
active?: boolean active?: boolean
title?: string title?: string
tooltip?: boolean
wide?: boolean
onClick?: () => void onClick?: () => void
}) { }) {
let title: HTMLSpanElement | undefined
const [truncated, setTruncated] = createSignal(false)
onMount(() => {
const element = title
if (!element) return
const sync = () => setTruncated(element.scrollWidth > element.clientWidth)
const measure = () => requestAnimationFrame(sync)
const observer = new ResizeObserver(sync)
observer.observe(element)
measure()
void document.fonts?.ready.then(measure)
onCleanup(() => observer.disconnect())
})
return ( return (
<AttachmentCardV2 title={props.comment} active={props.active} hover={props.title} onClick={props.onClick}> <TooltipV2
<FileIcon node={{ path: props.path, type: "file" }} /> placement="top"
<span> openDelay={1000}
{getFilenameTruncated(props.path, 14)} value={props.title ?? props.comment}
<Show when={props.selection}> disabled={!props.tooltip || !truncated()}
{(sel) => class={props.wide ? "w-full" : undefined}
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}` contentStyle={{ "max-width": "320px", "white-space": "pre-wrap" }}
} >
</Show> <AttachmentCardV2
</span> title={props.comment}
</AttachmentCardV2> active={props.active}
clickable={!!props.onClick}
wide={props.wide}
surface="base"
titleRef={(element) => {
title = element
}}
onClick={props.onClick}
>
<FileIcon node={{ path: props.path, type: "file" }} />
<span>
{getFilenameTruncated(props.path, 14)}
<Show when={props.selection}>
{(sel) =>
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
}
</Show>
</span>
</AttachmentCardV2>
</TooltipV2>
) )
} }

View file

@ -38,7 +38,8 @@
border-right-width: 0; border-right-width: 0;
} }
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"]:not([data-resizing]) { [data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar"][data-transition]:not([data-resizing]) {
transition: transition:
width 200ms cubic-bezier(0.22, 1, 0.36, 1), width 200ms cubic-bezier(0.22, 1, 0.36, 1),
border-right-width 200ms cubic-bezier(0.22, 1, 0.36, 1); border-right-width 200ms cubic-bezier(0.22, 1, 0.36, 1);

View file

@ -39,6 +39,7 @@ export type SessionReviewV2Props = {
export type SessionReviewV2SidebarProps = { export type SessionReviewV2SidebarProps = {
open: boolean open: boolean
transition: boolean
title?: JSX.Element title?: JSX.Element
stats?: JSX.Element stats?: JSX.Element
filter: string filter: string
@ -76,6 +77,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
<Show when={props.open}> <Show when={props.open}>
<aside <aside
data-slot="session-review-v2-sidebar" data-slot="session-review-v2-sidebar"
data-transition={props.transition ? "" : undefined}
data-resizing={resizing() ? "" : undefined} data-resizing={resizing() ? "" : undefined}
style={{ width: `${width()}px` }} style={{ width: `${width()}px` }}
> >

View file

@ -1,4 +1,4 @@
import { MetaProvider, Meta, Title } from "@solidjs/meta" import { MetaProvider, Title } from "@solidjs/meta"
import { Router } from "@solidjs/router" import { Router } from "@solidjs/router"
import { FileRoutes } from "@solidjs/start/router" import { FileRoutes } from "@solidjs/start/router"
import { Suspense } from "solid-js" import { Suspense } from "solid-js"
@ -9,12 +9,7 @@ import "./app.css"
function AppMeta() { function AppMeta() {
const i18n = useI18n() const i18n = useI18n()
return ( return <Title>{i18n.t("app.title")}</Title>
<>
<Title>{i18n.t("app.title")}</Title>
<Meta name="description" content={i18n.t("app.description")} />
</>
)
} }
export default function App() { export default function App() {

View file

@ -161,16 +161,11 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp
buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null), buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null),
), ),
) )
const title = createMemo(() => { const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - AI Model Comparison`)
if (props.family) const description = createMemo(
return `${props.family.first.name} vs ${props.family.second.name}: ${models()[0].name} vs ${models()[1].name}` () =>
return `${models()[0].name} vs ${models()[1].name} - Model Comparison` `Compare ${models()[0].name} from ${models()[0].labName} and ${models()[1].name} from ${models()[1].labName} on key metrics including benchmarks, price, context length, usage, and model features.`,
}) )
const description = createMemo(() => {
if (props.family)
return `Compare the latest ${props.family.first.name} and ${props.family.second.name} models: ${models()[0].name} vs ${models()[1].name}. See benchmarks, usage, price, context length, and features.`
return `Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.`
})
const canonicalPath = createMemo(() => { const canonicalPath = createMemo(() => {
if (props.family) return canonicalFamilyComparisonPath(props.family.first, props.family.second) if (props.family) return canonicalFamilyComparisonPath(props.family.first, props.family.second)
const first = firstCatalog() const first = firstCatalog()

View file

@ -200,6 +200,7 @@ export default function StatsModel() {
pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)} pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)}
title="Compare This Model" title="Compare This Model"
description="Other models to compare with this one." description="Other models to compare with this one."
variant="featured"
/> />
</> </>
</Show> </Show>

View file

@ -155,6 +155,7 @@ export default function StatsLab() {
pairs={labComparisonPairs(data(), stats()?.models ?? [])} pairs={labComparisonPairs(data(), stats()?.models ?? [])}
title={`${data().name} Model Comparisons`} title={`${data().name} Model Comparisons`}
description="Model pairs from this lab." description="Model pairs from this lab."
variant="featured"
/> />
</> </>
)} )}

View file

@ -74,7 +74,8 @@ export default function ModelCompareIndex() {
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system") const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
const compareUrl = createMemo(() => localizedUrl(language.locale(), comparePath)) const compareUrl = createMemo(() => localizedUrl(language.locale(), comparePath))
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString() const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
const featuredModels = createMemo(() => (catalog()?.models ?? []).slice(0, 120)) const models = createMemo(() => catalog()?.models ?? [])
const featuredModels = createMemo(() => models().slice(0, 120))
const categories = createMemo(() => buildComparisonCategories(featuredModels())) const categories = createMemo(() => buildComparisonCategories(featuredModels()))
const compareHeaderLinks = createMemo<readonly HeaderLink[]>(() => [ const compareHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
{ href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") }, { href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") },
@ -150,7 +151,7 @@ export default function ModelCompareIndex() {
</section> </section>
<section data-section="compare-home-selector" aria-label="Choose models to compare"> <section data-section="compare-home-selector" aria-label="Choose models to compare">
<Show <Show
when={featuredModels().length > 1} when={models().length > 1}
fallback={ fallback={
<div data-component="empty-state" data-compact="true"> <div data-component="empty-state" data-compact="true">
<strong>No models found</strong> <strong>No models found</strong>
@ -158,7 +159,7 @@ export default function ModelCompareIndex() {
</div> </div>
} }
> >
<CompareHomeSelector models={featuredModels()} /> <CompareHomeSelector models={models()} />
</Show> </Show>
</section> </section>
<ComparisonCardsSection <ComparisonCardsSection

View file

@ -3063,7 +3063,7 @@
min-width: 0; min-width: 0;
} }
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"] { [data-page="stats"] button[data-slot="stats-breadcrumb-select-trigger"] {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@ -3089,13 +3089,13 @@
cursor: pointer; cursor: pointer;
} }
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-current="true"], [data-page="stats"] button[data-slot="stats-breadcrumb-select-trigger"][data-current="true"],
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:hover, [data-page="stats"] button[data-slot="stats-breadcrumb-select-trigger"]:hover,
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-expanded] { [data-page="stats"] button[data-slot="stats-breadcrumb-select-trigger"][data-expanded] {
color: var(--stats-text); color: var(--stats-text);
} }
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:focus-visible { [data-page="stats"] button[data-slot="stats-breadcrumb-select-trigger"]:focus-visible {
color: var(--stats-text); color: var(--stats-text);
outline: 1px solid var(--stats-text); outline: 1px solid var(--stats-text);
outline-offset: 2px; outline-offset: 2px;
@ -3126,7 +3126,7 @@
} }
[data-page="stats"] [data-page="stats"]
[data-slot="stats-breadcrumb-select-trigger"][data-expanded] button[data-slot="stats-breadcrumb-select-trigger"][data-expanded]
[data-slot="stats-breadcrumb-select-icon"] { [data-slot="stats-breadcrumb-select-icon"] {
transform: rotate(180deg); transform: rotate(180deg);
} }
@ -6436,7 +6436,7 @@
white-space: nowrap; white-space: nowrap;
} }
[data-page="stats"] button[data-slot="compare-detail-select-model"] svg { [data-page="stats"] button[data-slot="compare-detail-select-model"] > svg {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--stats-muted); color: var(--stats-muted);
} }
@ -6510,6 +6510,7 @@
[data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] span { [data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] span {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex: 0 0 auto;
height: 24px; height: 24px;
padding: 0 8px; padding: 0 8px;
color: var(--stats-muted); color: var(--stats-muted);
@ -6517,6 +6518,7 @@
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
white-space: nowrap;
} }
[data-page="stats"] [data-slot="compare-detail-value"] { [data-page="stats"] [data-slot="compare-detail-value"] {
@ -7884,7 +7886,7 @@
max-width: min(100%, 240px); max-width: min(100%, 240px);
} }
[data-page="stats"] [data-component="model-hero-breadcrumb"] [data-slot="stats-breadcrumb-select-trigger"] { [data-page="stats"] [data-component="model-hero-breadcrumb"] button[data-slot="stats-breadcrumb-select-trigger"] {
max-width: min(100%, 240px); max-width: min(100%, 240px);
} }

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