chore: merge dev into v2
This commit is contained in:
commit
6911456c2f
122 changed files with 3190 additions and 754 deletions
2
.github/TEAM_MEMBERS
vendored
2
.github/TEAM_MEMBERS
vendored
|
|
@ -18,4 +18,4 @@ simonklee
|
||||||
Slickstef11
|
Slickstef11
|
||||||
usrnk1
|
usrnk1
|
||||||
vimtor
|
vimtor
|
||||||
starptech
|
StarpTech
|
||||||
|
|
|
||||||
|
|
@ -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="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 }]),
|
||||||
|
|
|
||||||
270
packages/app/e2e/regression/remote-session-settings.spec.ts
Normal file
270
packages/app/e2e/regression/remote-session-settings.spec.ts
Normal 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" })
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
34
packages/app/e2e/reproduction/timeline-suspense/index.html
Normal file
34
packages/app/e2e/reproduction/timeline-suspense/index.html
Normal 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>
|
||||||
315
packages/app/e2e/reproduction/timeline-suspense/main.tsx
Normal file
315
packages/app/e2e/reproduction/timeline-suspense/main.tsx
Normal 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")!)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { defineConfig, devices } from "@playwright/test"
|
||||||
|
|
||||||
|
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: ".",
|
||||||
|
testMatch: "timeline-suspense.repro.ts",
|
||||||
|
outputDir: "../../test-results/timeline-suspense",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: "line",
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
||||||
|
cwd: import.meta.dirname,
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
page.on("pageerror", (error) => console.error(error))
|
||||||
|
await page.goto("/")
|
||||||
|
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
||||||
|
await page.goto("/?reconnect=candidate")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
||||||
|
const before = await prepare(page)
|
||||||
|
await triggerBaselineSuspension(page)
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.nativeOffset).toBe(0)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.indexes).toEqual(before.indexes)
|
||||||
|
expect(pending.sameRoute).toBe(true)
|
||||||
|
expect(pending.sameViewport).toBe(true)
|
||||||
|
expect(pending.sameSurface).toBe(true)
|
||||||
|
expect(pending.sameMountedRows).toBe(true)
|
||||||
|
|
||||||
|
await resolveSuspension(page)
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||||
|
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(after.sameRoute).toBe(true)
|
||||||
|
expect(after.sameViewport).toBe(true)
|
||||||
|
expect(after.sameSurface).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(0)
|
||||||
|
expect(after.coreOffset).toBe(0)
|
||||||
|
expect(after.rangeStart).toBeLessThan(10)
|
||||||
|
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
||||||
|
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
||||||
|
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
||||||
|
expect(after.syntheticScrollDispatches).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const before = await prepare(page)
|
||||||
|
const beforeRows = before.domIndexes
|
||||||
|
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
||||||
|
expect(before.logicalSurfaceHeight).toBe(80_000)
|
||||||
|
expect(before.renderedSurfaceHeight).toBe(80_000)
|
||||||
|
expect(before.viewportClientHeight).toBe(600)
|
||||||
|
expect(before.viewportScrollHeight).toBe(80_000)
|
||||||
|
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||||
|
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||||
|
expect(before.visibleRows).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
await triggerBaselineSuspension(page)
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.resourceState).toBe("refreshing")
|
||||||
|
expect(pending.routeConnected).toBe(false)
|
||||||
|
expect(pending.viewportConnected).toBe(false)
|
||||||
|
expect(pending.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(pending.nativeOffset).toBe(0)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.rangeStart).toBe(before.rangeStart)
|
||||||
|
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
||||||
|
expect(pending.indexes).toEqual(before.indexes)
|
||||||
|
expect(pending.domIndexes).toEqual(beforeRows)
|
||||||
|
expect(pending.sameMountedRows).toBe(true)
|
||||||
|
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||||
|
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
||||||
|
expect(pending.mutationEvents).toHaveLength(1)
|
||||||
|
expect(pending.mutationEvents[0]).toMatchObject({
|
||||||
|
kind: "removed",
|
||||||
|
routeConnectedInCallback: false,
|
||||||
|
nativeOffsetInCallback: 0,
|
||||||
|
})
|
||||||
|
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
||||||
|
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
||||||
|
|
||||||
|
const after = await resolveSuspension(page)
|
||||||
|
expect(after.resourceState).toBe("ready")
|
||||||
|
expect(after.routeConnected).toBe(true)
|
||||||
|
expect(after.viewportConnected).toBe(true)
|
||||||
|
expect(after.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(after.sameRoute).toBe(true)
|
||||||
|
expect(after.sameViewport).toBe(true)
|
||||||
|
expect(after.sameSurface).toBe(true)
|
||||||
|
expect(after.sameMountedRows).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(0)
|
||||||
|
expect(after.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(after.rangeStart).toBe(before.rangeStart)
|
||||||
|
expect(after.rangeEnd).toBe(before.rangeEnd)
|
||||||
|
expect(after.indexes).toEqual(before.indexes)
|
||||||
|
expect(after.domIndexes).toEqual(beforeRows)
|
||||||
|
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||||
|
expect(after.mutationEvents).toHaveLength(2)
|
||||||
|
expect(after.mutationEvents[1]).toMatchObject({
|
||||||
|
kind: "added",
|
||||||
|
routeConnectedInCallback: true,
|
||||||
|
nativeOffsetInCallback: 0,
|
||||||
|
})
|
||||||
|
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
||||||
|
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
||||||
|
expect(after.visibleRows).toBe(0)
|
||||||
|
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
||||||
|
expect(after.syntheticScrollDispatches).toBe(0)
|
||||||
|
|
||||||
|
await page.locator("[data-viewport]").hover()
|
||||||
|
await page.mouse.wheel(0, 80)
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const value = window.timelineSuspense.snapshot()
|
||||||
|
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||||
|
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
||||||
|
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
||||||
|
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
||||||
|
expect(recovered.lastScrollTrusted).toBe(true)
|
||||||
|
expect(recovered.rangeStart).toBeLessThan(10)
|
||||||
|
expect(recovered.visibleRows).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
||||||
|
await page.goto("/?resource=guard")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
||||||
|
const before = await prepare(page)
|
||||||
|
|
||||||
|
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.routeConnected).toBe(true)
|
||||||
|
expect(pending.mutationEvents).toEqual([])
|
||||||
|
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.visibleRows).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const after = await resolveSuspension(page)
|
||||||
|
expect(after.routeConnected).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(before.nativeOffset)
|
||||||
|
expect(after.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(after.visibleRows).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function prepare(page: Page) {
|
||||||
|
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
||||||
|
expect(before.routeConnected).toBe(true)
|
||||||
|
expect(before.viewportConnected).toBe(true)
|
||||||
|
expect(before.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(before.sameMountedRows).toBe(true)
|
||||||
|
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||||
|
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||||
|
return before
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerBaselineSuspension(page: Page) {
|
||||||
|
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSuspension(page: Page) {
|
||||||
|
await page.evaluate(() => window.timelineSuspense.resolve())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
return page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(value: unknown) {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
import solid from "vite-plugin-solid"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: import.meta.dirname,
|
||||||
|
plugins: [solid()],
|
||||||
|
})
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
"./performance/timeline-stability/fixture.test.ts",
|
"./performance/timeline-stability/fixture.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"
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
BIN
packages/app/src/assets/help/home.png
Normal file
BIN
packages/app/src/assets/help/home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Binary file not shown.
BIN
packages/app/src/assets/help/tabs.png
Normal file
BIN
packages/app/src/assets/help/tabs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "New layout",
|
||||||
|
badge: "New",
|
||||||
|
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||||
|
noticeTitle: "You're now using new layout",
|
||||||
|
noticeDescription: "The previous layout is no longer available",
|
||||||
|
dismiss: "Dismiss",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Frame(props) {
|
||||||
|
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleExample(props) {
|
||||||
|
const [state, setState] = createStore({ checked: props.checked })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<LayoutTransitionToggle
|
||||||
|
title={copy.title}
|
||||||
|
badge={copy.badge}
|
||||||
|
description={copy.description}
|
||||||
|
checked={state.checked}
|
||||||
|
onChange={(checked) => setState("checked", checked)}
|
||||||
|
/>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoticeExample() {
|
||||||
|
const [state, setState] = createStore({ dismissed: false })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||||
|
<LayoutRetirementNotice
|
||||||
|
title={copy.noticeTitle}
|
||||||
|
description={copy.noticeDescription}
|
||||||
|
dismiss={copy.dismiss}
|
||||||
|
onDismiss={() => setState("dismissed", true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Settings/Layout transition",
|
||||||
|
id: "app-settings-layout-transition",
|
||||||
|
component: LayoutTransitionToggle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked={false} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutRetired = {
|
||||||
|
render: () => <NoticeExample />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates = {
|
||||||
|
render: () => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<ToggleExample checked />
|
||||||
|
<ToggleExample checked={false} />
|
||||||
|
<NoticeExample />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
|
||||||
|
export function LayoutTransitionToggle(props: {
|
||||||
|
title: string
|
||||||
|
badge: string
|
||||||
|
description: string
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="settings-v2-interface-feature">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{props.title}
|
||||||
|
<Tag variant="accent">{props.badge}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={props.description}
|
||||||
|
>
|
||||||
|
<div data-action="settings-new-layout-designs">
|
||||||
|
<Switch checked={props.checked} onChange={props.onChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutRetirementNotice(props: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dismiss: string
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2 title={props.title} description={props.description}>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||||
|
{props.dismiss}
|
||||||
|
</ButtonV2>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -90,6 +90,11 @@
|
||||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
72
packages/app/src/context/settings.test.ts
Normal file
72
packages/app/src/context/settings.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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 الأصلي",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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+スクロール操作によるズームを許可します",
|
||||||
|
|
|
||||||
|
|
@ -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": "다운로드 중...",
|
||||||
|
|
|
||||||
|
|
@ -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...",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 แบบเนทีฟ",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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`}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||||
|
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||||
|
|
||||||
|
test("matches only the scroll element or an ancestor containing it", () => {
|
||||||
|
const route = document.createElement("section")
|
||||||
|
const viewport = document.createElement("div")
|
||||||
|
const child = document.createElement("div")
|
||||||
|
const sibling = document.createElement("div")
|
||||||
|
route.append(viewport)
|
||||||
|
viewport.append(child)
|
||||||
|
|
||||||
|
expect(mutationNodesContainElement([viewport], viewport)).toBe(true)
|
||||||
|
expect(mutationNodesContainElement([route], viewport)).toBe(true)
|
||||||
|
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||||
|
const route = document.createElement("section")
|
||||||
|
const viewport = document.createElement("div")
|
||||||
|
const unrelated = document.createElement("div")
|
||||||
|
route.append(viewport)
|
||||||
|
document.body.append(route)
|
||||||
|
const instance = {
|
||||||
|
scrollElement: viewport,
|
||||||
|
targetWindow: window,
|
||||||
|
scrollOffset: 79_400,
|
||||||
|
options: {
|
||||||
|
horizontal: false,
|
||||||
|
isRtl: false,
|
||||||
|
isScrollingResetDelay: 0,
|
||||||
|
useScrollendEvent: false,
|
||||||
|
},
|
||||||
|
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||||
|
const calls: [number, boolean][] = []
|
||||||
|
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
|
||||||
|
calls.push([offset, isScrolling])
|
||||||
|
instance.scrollOffset = offset
|
||||||
|
})
|
||||||
|
|
||||||
|
document.body.append(unrelated)
|
||||||
|
unrelated.remove()
|
||||||
|
await frames(2)
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
|
||||||
|
route.remove()
|
||||||
|
document.body.append(route)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await frames(3)
|
||||||
|
expect(calls).toEqual([[0, false]])
|
||||||
|
|
||||||
|
route.remove()
|
||||||
|
document.body.append(route)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await frames(3)
|
||||||
|
expect(calls).toEqual([[0, false]])
|
||||||
|
|
||||||
|
cleanup?.()
|
||||||
|
route.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||||
|
const route = document.createElement("section")
|
||||||
|
const viewport = document.createElement("div")
|
||||||
|
route.append(viewport)
|
||||||
|
document.body.append(route)
|
||||||
|
const instance = {
|
||||||
|
scrollElement: viewport,
|
||||||
|
targetWindow: window,
|
||||||
|
scrollOffset: 79_400,
|
||||||
|
options: {
|
||||||
|
horizontal: false,
|
||||||
|
isRtl: false,
|
||||||
|
isScrollingResetDelay: 20,
|
||||||
|
useScrollendEvent: false,
|
||||||
|
},
|
||||||
|
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||||
|
const calls: number[] = []
|
||||||
|
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => {
|
||||||
|
calls.push(offset)
|
||||||
|
instance.scrollOffset = offset
|
||||||
|
})
|
||||||
|
|
||||||
|
route.remove()
|
||||||
|
document.body.append(route)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await frames(1)
|
||||||
|
expect(instance.scrollOffset).toBe(0)
|
||||||
|
|
||||||
|
instance.scrollOffset = 79_400
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||||
|
await frames(3)
|
||||||
|
|
||||||
|
expect(instance.scrollOffset).toBe(0)
|
||||||
|
expect(calls).toEqual([0, 0])
|
||||||
|
cleanup?.()
|
||||||
|
route.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ name: "LTR", isRtl: false, expected: 240 },
|
||||||
|
{ name: "RTL", isRtl: true, expected: -240 },
|
||||||
|
])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => {
|
||||||
|
const route = document.createElement("section")
|
||||||
|
const viewport = document.createElement("div")
|
||||||
|
route.append(viewport)
|
||||||
|
document.body.append(route)
|
||||||
|
viewport.scrollLeft = 240
|
||||||
|
const instance = {
|
||||||
|
scrollElement: viewport,
|
||||||
|
targetWindow: window,
|
||||||
|
scrollOffset: 0,
|
||||||
|
options: {
|
||||||
|
horizontal: true,
|
||||||
|
isRtl,
|
||||||
|
isScrollingResetDelay: 0,
|
||||||
|
useScrollendEvent: false,
|
||||||
|
},
|
||||||
|
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||||
|
const calls: [number, boolean][] = []
|
||||||
|
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
|
||||||
|
calls.push([offset, isScrolling])
|
||||||
|
instance.scrollOffset = offset
|
||||||
|
})
|
||||||
|
|
||||||
|
route.remove()
|
||||||
|
document.body.append(route)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await frames(3)
|
||||||
|
|
||||||
|
expect(calls).toEqual([[expected, false]])
|
||||||
|
cleanup?.()
|
||||||
|
route.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleanup suppresses an already queued delegated offset callback", async () => {
|
||||||
|
const viewport = document.createElement("div")
|
||||||
|
document.body.append(viewport)
|
||||||
|
viewport.scrollTop = 100
|
||||||
|
const instance = {
|
||||||
|
scrollElement: viewport,
|
||||||
|
targetWindow: window,
|
||||||
|
scrollOffset: 0,
|
||||||
|
options: {
|
||||||
|
horizontal: false,
|
||||||
|
isRtl: false,
|
||||||
|
isScrollingResetDelay: 10,
|
||||||
|
useScrollendEvent: false,
|
||||||
|
},
|
||||||
|
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||||
|
const calls: [number, boolean][] = []
|
||||||
|
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) =>
|
||||||
|
calls.push([offset, isScrolling]),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewport.dispatchEvent(new Event("scroll"))
|
||||||
|
cleanup?.()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||||
|
|
||||||
|
expect(calls).toEqual([[100, true]])
|
||||||
|
viewport.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleanup cancels reconnect checks and delegated offset observation", async () => {
|
||||||
|
const route = document.createElement("section")
|
||||||
|
const viewport = document.createElement("div")
|
||||||
|
route.append(viewport)
|
||||||
|
document.body.append(route)
|
||||||
|
const instance = {
|
||||||
|
scrollElement: viewport,
|
||||||
|
targetWindow: window,
|
||||||
|
scrollOffset: 0,
|
||||||
|
options: {
|
||||||
|
horizontal: false,
|
||||||
|
isRtl: false,
|
||||||
|
isScrollingResetDelay: 50,
|
||||||
|
useScrollendEvent: false,
|
||||||
|
},
|
||||||
|
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||||
|
const calls: number[] = []
|
||||||
|
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset))
|
||||||
|
|
||||||
|
route.remove()
|
||||||
|
document.body.append(route)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
cleanup?.()
|
||||||
|
instance.scrollOffset = 100
|
||||||
|
viewport.dispatchEvent(new Event("scroll"))
|
||||||
|
await frames(4)
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
route.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function frames(count: number) {
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
|
||||||
|
|
||||||
|
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
|
||||||
|
instance: Virtualizer<TScrollElement, TItemElement>,
|
||||||
|
callback: (offset: number, isScrolling: boolean) => void,
|
||||||
|
) {
|
||||||
|
let active = true
|
||||||
|
const deliver = (offset: number, isScrolling: boolean) => {
|
||||||
|
if (!active) return
|
||||||
|
callback(offset, isScrolling)
|
||||||
|
}
|
||||||
|
const cleanupOffset = observeElementOffset(instance, deliver)
|
||||||
|
const element = instance.scrollElement
|
||||||
|
const targetWindow = instance.targetWindow
|
||||||
|
const root = element?.closest("main") ?? element?.ownerDocument.body
|
||||||
|
if (!element || !targetWindow || !root)
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
cleanupOffset?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = false
|
||||||
|
let frame: number | undefined
|
||||||
|
const clearCheck = () => {
|
||||||
|
if (frame === undefined) return
|
||||||
|
targetWindow.cancelAnimationFrame(frame)
|
||||||
|
frame = undefined
|
||||||
|
}
|
||||||
|
const startCheck = () => {
|
||||||
|
clearCheck()
|
||||||
|
const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay
|
||||||
|
let framesAfterDeadline = 0
|
||||||
|
const check = (time: number) => {
|
||||||
|
frame = undefined
|
||||||
|
if (element.isConnected) {
|
||||||
|
const offset = instance.options.horizontal
|
||||||
|
? element.scrollLeft * (instance.options.isRtl ? -1 : 1)
|
||||||
|
: element.scrollTop
|
||||||
|
if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false)
|
||||||
|
}
|
||||||
|
if (time >= deadline) framesAfterDeadline += 1
|
||||||
|
if (framesAfterDeadline >= 2) return
|
||||||
|
frame = targetWindow.requestAnimationFrame(check)
|
||||||
|
}
|
||||||
|
frame = targetWindow.requestAnimationFrame(check)
|
||||||
|
}
|
||||||
|
const observer = new targetWindow.MutationObserver((records) => {
|
||||||
|
if (!active) return
|
||||||
|
records.forEach((record) => {
|
||||||
|
if (record.target === element || element.contains(record.target)) return
|
||||||
|
if (mutationNodesContainElement(record.removedNodes, element)) {
|
||||||
|
removed = true
|
||||||
|
clearCheck()
|
||||||
|
}
|
||||||
|
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
|
||||||
|
removed = false
|
||||||
|
startCheck()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Session routes are replaced below persistent main; body is the fallback for isolated hosts.
|
||||||
|
observer.observe(root, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
observer.disconnect()
|
||||||
|
clearCheck()
|
||||||
|
cleanupOffset?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mutationNodesContainElement(nodes: Iterable<Node>, element: Element) {
|
||||||
|
return [...nodes].some((node) => node === element || node.contains(element))
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
65
packages/app/test-browser/review-panel-v2-state.test.ts
Normal file
65
packages/app/test-browser/review-panel-v2-state.test.ts
Normal 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" }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
19
packages/desktop/src/main/install-state.test.ts
Normal file
19
packages/desktop/src/main/install-state.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
8
packages/desktop/src/main/install-state.ts
Normal file
8
packages/desktop/src/main/install-state.ts
Normal 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"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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` }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue