diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index 5eb465e0dd..ee2e26f452 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -18,4 +18,4 @@ simonklee Slickstef11 usrnk1 vimtor -starptech +StarpTech diff --git a/nix/hashes.json b/nix/hashes.json index 20e5882de0..9d85faba7f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=", - "aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=", - "aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=", - "x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw=" + "x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=", + "aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=", + "aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=", + "x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw=" } } diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index e63445e87d..5095d95db0 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -136,6 +136,9 @@ export async function setupTimeline( }, }), ) + if (settings.newLayoutDesigns === false) { + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) + } }, input.settings ?? {}) if (input.locale) { await page.addInitScript((locale) => { diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts index 30233a5aae..45cd64adcc 100644 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => { await page.addInitScript( ({ directory, draftID, server }) => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([{ type: "draft", draftID, server, directory }]), diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts new file mode 100644 index 0000000000..c17ae5c1c6 --- /dev/null +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -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 }>(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" }) +} diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 15375cafed..e5ef7998ea 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" -const assistants = Array.from({ length: 14 }, (_, index) => +const initialPageSize = 20 +const historyPageSize = 200 +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, parentID: userID, created: 1700000001000 + index * 1_000, - completed: index < 13, + completed: index < initialPageSize, }), ) const messages = [userMessage(), ...assistants] @@ -46,7 +48,7 @@ const scenarios = [ test.use({ viewport: { width: 646, height: 1385 } }) for (const scenario of scenarios) { - test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => { + test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { const requests: { before?: string; phase: "start" | "end" }[] = [] const pages: { before?: string; limit: number }[] = [] const roots: { sessionID: string; messageID: string }[] = [] @@ -101,36 +103,51 @@ for (const scenario of scenarios) { } }, }) - await page.addInitScript( - ({ userPartID, lastPartID }) => { - const state = { armed: false, hidden: false, samples: 0, stop: false } - ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state - const sample = () => { - if (state.armed) { - const virtual = document.querySelector("[data-timeline-virtual-content]") - const viewport = virtual?.closest(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - const visible = (partID: string) => { - const part = viewport?.querySelector(`[data-timeline-part-id="${partID}"]`) - const rect = part?.getBoundingClientRect() - return ( - !!rect && - !!view && - rect.width > 0 && - rect.height > 0 && - rect.bottom > view.top && - rect.top < view.bottom - ) - } - if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true - state.samples++ + await page.addInitScript(() => { + const visibleParts = () => { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + if (!viewport || !view) return [] + return [...viewport.querySelectorAll("[data-timeline-part-id]")] + .filter((part) => { + const rect = part.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) + } + const state = { + armed: false, + hidden: false, + visibleParts: [] as string[], + samples: 0, + stop: false, + arm() { + state.visibleParts = visibleParts() + state.armed = true + }, + } + ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state + const sample = () => { + if (state.armed) { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector(`[data-timeline-part-id="${CSS.escape(partID)}"]`) + const rect = part?.getBoundingClientRect() + return ( + !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + ) } - if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) + state.hidden = true + state.samples++ } - requestAnimationFrame(() => setTimeout(sample, 0)) - }, - { userPartID, lastPartID }, - ) + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await transport.waitForConnection() @@ -143,23 +160,28 @@ for (const scenario of scenarios) { "messages:start:latest", "messages:end:latest", `message:${userID}`, - `messages:start:${messages.at(-2)!.info.id}`, + `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await page.evaluate(() => { ;( window as Window & { - __historyRootProbe?: { armed: boolean } + __historyRootProbe?: { arm(): void } } - ).__historyRootProbe!.armed = true + ).__historyRootProbe!.arm() }) await waitForProbeSamples(page, 0) - expect(await historyRootHidden(page)).toBe(false) + expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) - expect(pages[0]).toEqual({ before: undefined, limit: 2 }) + expect(pages).toEqual([ + { before: undefined, limit: initialPageSize }, + { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, + ]) expect(roots).toEqual([{ sessionID, messageID: userID }]) const message = messageUpdated(scenario.info) @@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) { ) } -function historyRootHidden(page: Page) { +function visibleContentHidden(page: Page) { return page.evaluate( () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, ) diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index 2c2801d4b5..f672602782 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus" const projectID = "proj_terminal_composer_focus" const sessionID = "ses_terminal_composer_focus" const ptyID = "pty_terminal_composer_focus" +const newPtyID = "pty_terminal_composer_focus_new" test.use({ viewport: { width: 1440, height: 900 } }) -test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { +test.beforeEach(async ({ page }) => { await mockOpenCodeServer(page, { directory, project: { @@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) +}) +test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async await expect(composer).toBeFocused() await expect(composer).toHaveText("a") }) + +test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + 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() + const release = Promise.withResolvers() + 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 }, + ) +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html new file mode 100644 index 0000000000..995008a9f7 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/index.html @@ -0,0 +1,34 @@ + + + + + + Timeline Suspense Reproduction + + + +
+ + + diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx new file mode 100644 index 0000000000..cdcd809be3 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/main.tsx @@ -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 + trigger: () => void + resolve: () => void + frames: (count?: number) => Promise + snapshot: () => Snapshot + } + } +} + +function App() { + const [refresh, setRefresh] = createSignal(false) + let resolveResource: (() => void) | undefined + const [resource] = createResource( + refresh, + (version) => + new Promise((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({ + 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((resolve) => requestAnimationFrame(() => resolve())) + } + } + const mountedRows = () => [...(surface?.querySelectorAll("[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 ( +
+ +
+
+ + {(item) => ( +
+ logical row {item.index} +
+ )} +
+
+
+
+ ) + } + + return ( +
+ + + +
+ ) +} + +render(() => , document.getElementById("root")!) diff --git a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts new file mode 100644 index 0000000000..7fff1737ae --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts @@ -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"] }, + }, + ], +}) diff --git a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts new file mode 100644 index 0000000000..3edae33a9b --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts @@ -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) +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts new file mode 100644 index 0000000000..efb75ab731 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" + +export default defineConfig({ + root: import.meta.dirname, + plugins: [solid()], +}) diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 53aacbda02..4a6046e4fa 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -10,6 +10,9 @@ "./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.ts", "./performance/unit/visual-stability.test.ts", + "./reproduction/timeline-suspense/**/*.ts", + "./reproduction/timeline-suspense/**/*.tsx", + "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", "./utils/**/*.ts" diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 186962998d..55420485f3 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(input, init) + return originalFetch(request) const id = ++nextConnectionID const record = { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 86263d172d..5592e2f4ea 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() { } // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected -// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ -// Notification/Models + the visual Layout) for that server. +// server via ServerKey, then provide the server-scoped shell for that server. function SelectedServerProviders(props: ParentProps) { return ( @@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -309,24 +308,21 @@ function DesktopCommands() { // Server-scoped providers shared by the legacy shell and the top-level new shell. type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined - sessionID?: () => string | undefined serverScoped?: JSX.Element }> function ServerScopedProviders(props: ServerScopedShellProps) { return ( - - - {props.serverScoped} - {props.children} - - + + {props.serverScoped} + {props.children} + ) } function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) @@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { ) } -function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) { - return ( - - {props.children} - - ) -} - // The draft page only renders the prompt composer, so it drops TerminalProvider. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. function DraftProviders(props: ParentProps) { @@ -559,13 +547,15 @@ export function AppInterface(props: { component={props.router ?? Router} root={(routerProps) => ( - - - - {routerProps.children} - - - + + + + + {routerProps.children} + + + + )} > diff --git a/packages/app/src/assets/help/home.png b/packages/app/src/assets/help/home.png new file mode 100644 index 0000000000..aeca6d977a Binary files /dev/null and b/packages/app/src/assets/help/home.png differ diff --git a/packages/app/src/assets/help/introducing-tabs.mp4 b/packages/app/src/assets/help/introducing-tabs.mp4 index 2bcc8aeef6..fd46be1f46 100644 Binary files a/packages/app/src/assets/help/introducing-tabs.mp4 and b/packages/app/src/assets/help/introducing-tabs.mp4 differ diff --git a/packages/app/src/assets/help/tabs.png b/packages/app/src/assets/help/tabs.png new file mode 100644 index 0000000000..d2c6e68a3d Binary files /dev/null and b/packages/app/src/assets/help/tabs.png differ diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index de156e1b33..868be691a6 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,11 +1,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { createSignal, Show } from "solid-js" -import { createStore } from "solid-js/store" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" -import { Persist, persisted } from "@/utils/persist" +import homeImage from "@/assets/help/home.png" +import tabsImage from "@/assets/help/tabs.png" const helpIcon = ( - +
@@ -127,16 +125,32 @@ export function TabsInfoPopup() { icon={} /> -
+

- Introducing Tabs Navigation. -

-

- We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at - the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch - contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the - sessions. + Introducing Tabs

+
+

OpenCode Desktop is now built around tabs.

+ +

+ 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. +

+

+ 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. +

+

+ You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab. +

+ +

When you reopen the app, your tabs are still open.

+

+ 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. +

+
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 284e04447f..a820067a4e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -2203,10 +2203,7 @@ type ComposerModelControlState = { function ComposerAgentControl(props: { state: ComposerAgentControlState }) { return ( -
-
- -
+
} > -