diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts index f318037373b..627061af7b7 100644 --- a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +++ b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts @@ -123,7 +123,7 @@ async function setup(page: Page) { }) await page.addInitScript( - ({ directory, server, sessionID }) => { + ({ directory, server, sessionID, tabKey }) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ @@ -131,10 +131,8 @@ async function setup(page: Page) { lastProject: { local: directory }, }), ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } })) + localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } })) localStorage.setItem( "opencode.global.dat:review-panel-v2", JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), @@ -144,6 +142,6 @@ async function setup(page: Page) { JSON.stringify([{ type: "session", server, sessionId: sessionID }]), ) }, - { directory, server, sessionID }, + { directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` }, ) } diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts index e66fb6deea6..44b34e1fc40 100644 --- a/packages/app/e2e/regression/open-file-expand-folder.spec.ts +++ b/packages/app/e2e/regression/open-file-expand-folder.spec.ts @@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa }) await page.addInitScript( - ({ directory, server, sessionID }) => { + ({ directory, server, sessionID, tabKey }) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ @@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa lastProject: { local: directory }, }), ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } })) + localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } })) localStorage.setItem( "opencode.global.dat:review-panel-v2", JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), @@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa JSON.stringify([{ type: "session", server, sessionId: sessionID }]), ) }, - { directory, server, sessionID }, + { directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` }, ) await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 7cc47f356c6..a67f12bbc89 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => { pageMessages: () => ({ items: [] }), }) await page.addInitScript( - ({ directory, server, sessionID }) => { + ({ directory, server, sessionID, tabKey }) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ @@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => { lastProject: { local: directory }, }), ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } })) + localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } })) localStorage.setItem( "opencode.global.dat:review-panel-v2", JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }), @@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => { JSON.stringify([{ type: "session", server, sessionId: sessionID }]), ) }, - { directory, server, sessionID }, + { directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` }, ) await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 84b7254bc84..74af4c02e24 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => { await selectFile(page, "alpha.ts") await switchSession(page, titleB) + await page.getByRole("button", { name: "Toggle review" }).click() await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await selectFile(page, "gamma.ts") diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index 29b8e260d20..26a216ed344 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -18,37 +18,72 @@ const PROBE = "original" test.use({ viewport: { width: 1440, height: 900 } }) -// The v2 review pane's diff data is workspace-scoped: switching between session -// tabs in the same workspace must update its parameters reactively instead of -// tearing the pane down and remounting it (which flickers). -test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => { +// The review pane's data is workspace-scoped, but visibility belongs to each +// session tab. Switching tabs must keep the pane mounted without opening it. +test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => { await setup(page) await page.goto(sessionHref(sessionA)) await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() + await expect + .poll(() => + page + .locator('[data-slot="session-chat-panel"]') + .evaluate((element) => getComputedStyle(element).transitionDuration), + ) + .toContain("0.24s") const reviewTab = page.locator("#session-side-panel-review-tab") const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") + const chatPanel = page.locator('[data-slot="session-chat-panel"]') await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") const review = page.locator('#review-panel [data-component="session-review-v2"]') await expectAppVisible(review) + await expect(chatPanel).toHaveCSS("width", "580px") await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) await writeProbe(page) await switchTab(page, titleB) await expectSessionTitle(page, titleB) - await expectAppVisible(review) - await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) + await expect(review).toBeHidden() + await expect + .poll(() => + page + .locator('[data-slot="session-chat-panel"]') + .evaluate((element) => getComputedStyle(element).transitionDuration), + ) + .toBe("0s") expect(await readProbe(page)).toBe(PROBE) + await page.getByRole("button", { name: "Toggle review" }).click() + await expectAppVisible(review) + await expect(chatPanel).toHaveCSS("width", "520px") + await switchTab(page, titleA) await expectSessionTitle(page, titleA) await expectAppVisible(review) + await expect(chatPanel).toHaveCSS("width", "580px") await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) expect(await readProbe(page)).toBe(PROBE) + await expect + .poll(() => + page.evaluate( + ({ key }) => { + const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}") + return panes[key]?.review + }, + { key: `${server}\n${sessionHref(sessionA)}` }, + ), + ) + .toBe(true) + await page.reload() + await expectSessionTitle(page, titleA) + await expectAppVisible(review) + await expect(chatPanel).toHaveCSS("width", "580px") + const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') await viewport.hover() await page.mouse.wheel(0, 100_000) @@ -102,7 +137,7 @@ async function setup(page: Page) { }) await page.addInitScript( - ({ directory, server, sessions }) => { + ({ directory, server, sessions, panes }) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ @@ -114,8 +149,19 @@ async function setup(page: Page) { "opencode.window.browser.dat:tabs", JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), ) + if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) { + localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes)) + } + }, + { + directory, + server, + sessions: [sessionA, sessionB], + panes: { + [`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 }, + [`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 }, + }, }, - { directory, server, sessions: [sessionA, sessionB] }, ) } diff --git a/packages/app/e2e/regression/review-terminal-bottom.spec.ts b/packages/app/e2e/regression/review-terminal-bottom.spec.ts index c8efac235dd..86c2488f140 100644 --- a/packages/app/e2e/regression/review-terminal-bottom.spec.ts +++ b/packages/app/e2e/regression/review-terminal-bottom.spec.ts @@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom }), ) await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined) - await page.addInitScript(() => { - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - }) + await page.addInitScript( + ({ tabKey, server, sessionID }) => { + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } })) + localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID }, + ) await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionReady(page, { server, sessionID, title }) diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index a0325cb335c..db4a435c8a7 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page }) function seedCachedTerminal(page: Page) { return page.addInitScript( - ({ terminalKey, ptyID }) => { - localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) + ({ terminalKey, ptyID, tabKey, server, sessionID }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs.panes", + JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) localStorage.setItem( terminalKey, JSON.stringify({ @@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) { }), ) }, - { terminalKey: terminalStorageKey(), ptyID }, + { + terminalKey: terminalStorageKey(), + ptyID, + tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, + server, + sessionID, + }, ) } diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index 96331f2a3e1..609662a2cdd 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -16,10 +16,9 @@ const PROBE = "original" test.use({ viewport: { width: 1440, height: 900 } }) -// Terminals are workspace-scoped: switching between session tabs in the same -// workspace must keep the terminal mounted and its PTY connection open instead -// of tearing it down and reconnecting. -test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => { +// Terminal processes are workspace-scoped, but panel visibility belongs to each +// session tab. Switching tabs must keep the PTY alive without opening its panel. +test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => { const connections = await setup(page) await page.goto(sessionHref(sessionA)) @@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac await page.keyboard.press("Control+Backquote") const terminal = page.locator('[data-component="terminal"]') + const terminalPanel = page.locator('[data-component="terminal-panel"]') await expect(terminal).toBeVisible() + await expect(terminalPanel).toHaveAttribute("data-size-animated", "true") + await expect(terminalPanel).toHaveCSS("height", "300px") await expect.poll(() => connections.length).toBe(1) const connection = new URL(connections[0]!) expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) @@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac await switchTab(page, titleB) await expectSessionTitle(page, titleB) - await expect(terminal).toBeVisible() + await expect(terminal).toBeHidden() + await expect(terminalPanel).toHaveAttribute("data-size-animated", "false") expect(await readProbe(page)).toBe(PROBE) expect(connections.length).toBe(1) + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await expect(terminalPanel).toHaveCSS("height", "180px") + await switchTab(page, titleA) await expectSessionTitle(page, titleA) await expect(terminal).toBeVisible() + await expect(terminalPanel).toHaveCSS("height", "300px") expect(await readProbe(page)).toBe(PROBE) expect(connections.length).toBe(1) + + await page.reload() + await expectSessionTitle(page, titleA) + await expect(terminal).toBeVisible() + await expect(terminalPanel).toHaveCSS("height", "300px") }) type Probed = HTMLElement & { __e2eProbe?: string } @@ -120,7 +133,7 @@ async function setup(page: Page) { }) await page.addInitScript( - ({ directory, server, sessions }) => { + ({ directory, server, sessions, panes }) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ @@ -132,8 +145,20 @@ async function setup(page: Page) { "opencode.window.browser.dat:tabs", JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), ) + if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) { + localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes)) + } + localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } })) + }, + { + directory, + server, + sessions: [sessionA, sessionB], + panes: { + [`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 }, + [`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 }, + }, }, - { directory, server, sessions: [sessionA, sessionB] }, ) return connections } diff --git a/packages/app/src/runtime/animated-presence.ts b/packages/app/src/runtime/animated-presence.ts index b8492e5dcc7..4923e9638d2 100644 --- a/packages/app/src/runtime/animated-presence.ts +++ b/packages/app/src/runtime/animated-presence.ts @@ -1,16 +1,25 @@ import { createMemo, type Accessor } from "solid-js" import createPresence from "solid-presence" -export function createAnimatedPresence(value: Accessor, element: Accessor) { - const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => { - const current = value() - const show = current !== undefined - return { - show, - animate: previous !== undefined && (previous.animate || previous.show !== show), - value: current ?? previous?.value, - } - }) +export function createAnimatedPresence( + value: Accessor, + element: Accessor, + identity?: Accessor, +) { + const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>( + (previous) => { + const currentIdentity = identity?.() + const current = value() + const show = current !== undefined + const same = !identity || previous?.identity === currentIdentity + return { + identity: currentIdentity, + show, + animate: previous !== undefined && same && (previous.animate || previous.show !== show), + value: current ?? (same ? previous?.value : undefined), + } + }, + ) const presence = createPresence({ show: () => animation().show, element }) return { ...presence, diff --git a/packages/app/src/session/model.ts b/packages/app/src/session/model.ts index e52bb57bbef..4bf7d971350 100644 --- a/packages/app/src/session/model.ts +++ b/packages/app/src/session/model.ts @@ -129,6 +129,7 @@ export function useSessionModel() { layout: { tabs: layout.tabs, view: layout.view, + tabKey: layout.tabKey, }, ownership: createSessionOwnership(layout.sessionKey), tabs: { diff --git a/packages/app/src/session/screen-layout.ts b/packages/app/src/session/screen-layout.ts index 2709a070608..570c0baffe9 100644 --- a/packages/app/src/session/screen-layout.ts +++ b/packages/app/src/session/screen-layout.ts @@ -12,6 +12,7 @@ export function createSessionScreenLayout(session: SessionModel) { const layout = useLayout() const settings = useSettings() const size = createSizing() + const view = session.layout.view const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened()) const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id) const terminalOpen = createMemo(() => session.layout.view().terminal.opened()) @@ -42,7 +43,7 @@ export function createSessionScreenLayout(session: SessionModel) { const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split") const resizedWidth = createMemo(() => clampSessionPanelWidth({ - width: layout.session.width(), + width: view().reviewPanel.width(), available: available(), split: splitReview(), }), @@ -72,7 +73,7 @@ export function createSessionScreenLayout(session: SessionModel) { }, panelLayout().stacked) const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen()) const terminalPane = createMemo(() => - Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6), + Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6), ) const terminalPaneHeight = createMemo(() => `${terminalPane()}px`) const sideHeight = createMemo(() => rowSize.height) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 26b32f9f1f0..5643a68e78c 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -11,10 +11,8 @@ import { on, } from "solid-js" import { createStore } from "solid-js/store" -import createPresence from "solid-presence" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { SessionHeader } from "@/session/header/session-header" -import { useLayout } from "@/shell/state/layout" import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline" import { useServer } from "@/runtime/server/current" import { projectForSession } from "@/shell/layout/helpers" @@ -31,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab" import { createSessionTimelineInteraction } from "./timeline/interaction" import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region" import { SessionIdentityHeader } from "./session-identity-header" +import { createAnimatedPresence } from "@/runtime/animated-presence" const SessionMobileFiles = lazy(async () => { const { SessionMobileFiles } = await import("./files/session-mobile-files") @@ -39,7 +38,6 @@ const SessionMobileFiles = lazy(async () => { export function SessionScreen(props: { session: SessionModel }) { const session = props.session - const layout = useLayout() const server = useServer() const detailsProject = createMemo(() => { const info = session.data.info() @@ -66,14 +64,40 @@ export function SessionScreen(props: { session: SessionModel }) { const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible) const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open()) const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom()) - const sidePresence = createPresence({ - show: sideVisible, - element: () => elements.side ?? null, - }) - const bottomTerminalPresence = createPresence({ - show: bottomTerminalVisible, - element: () => elements.bottomTerminal ?? null, + const sidePresence = createAnimatedPresence( + () => sideVisible() || undefined, + () => elements.side ?? null, + session.layout.tabKey, + ) + const bottomTerminalPresence = createAnimatedPresence( + () => bottomTerminalVisible() || undefined, + () => elements.bottomTerminal ?? null, + session.layout.tabKey, + ) + const sideMotion = createMemo<{ + key?: string + region: boolean + terminal: boolean + animateRegion: boolean + animateTerminal: boolean + }>((previous) => { + const key = session.layout.tabKey() + const region = screen.side.region.open() + const terminal = sideTerminalVisible() + const sameTab = previous?.key === key + return { + key, + region, + terminal, + animateRegion: !!previous && sameTab && previous.region !== region, + animateTerminal: !!previous && sameTab && previous.terminal !== terminal, + } }) + const paneAnimating = () => + sidePresence.animate() || + sideMotion().animateRegion || + sideMotion().animateTerminal || + bottomTerminalPresence.animate() createEffect(() => { if (sideTerminalVisible()) setStore("sideTerminalPresent", true) if (bottomTerminalVisible()) setStore("bottomTerminalCached", true) @@ -256,7 +280,8 @@ export function SessionScreen(props: { session: SessionModel }) { classList={{ "@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true, "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": - !screen.size.active(), + !screen.size.active() && sidePresence.animate(), + "transition-none": screen.size.active() || !sidePresence.animate(), }} data-slot="session-chat-panel" style={{ @@ -279,18 +304,18 @@ export function SessionScreen(props: { session: SessionModel }) { max={screen.panel.max()} onResize={(width) => { screen.size.touch() - layout.session.resize(width) + session.layout.view().reviewPanel.resize(width) }} /> - +
setElements("side", element)} data-slot="session-side-panel-presence" - data-opened={sideVisible()} + data-opened={sidePresence.animate() ? sidePresence.show() : undefined} onAnimationEnd={(event) => { if (event.currentTarget !== event.target) return if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return @@ -311,15 +336,15 @@ export function SessionScreen(props: { session: SessionModel }) { data-slot="session-side-region" classList={{ "absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true, - "will-change-[height]": !screen.size.active() && store.sideHeightMotion, - "transition-none": screen.size.active() || !store.sideHeightMotion, + "will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(), + "transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(), }} style={{ height: screen.side.region.height() }} >
{ if (event.currentTarget !== event.target) return @@ -341,6 +366,7 @@ export function SessionScreen(props: { session: SessionModel }) { "relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true, "delay-0": !screen.side.gap.closing(), "delay-[200ms]": screen.side.gap.closing(), + "transition-none": !paneAnimating(), }} style={{ height: screen.side.gap.height() }} onPointerDown={() => screen.size.start()} @@ -349,13 +375,13 @@ export function SessionScreen(props: { session: SessionModel }) { { screen.size.touch() - layout.terminal.resize(height) + session.layout.view().terminal.resize(height) }} onCollapse={() => session.layout.view().terminal.close()} /> @@ -365,15 +391,15 @@ export function SessionScreen(props: { session: SessionModel }) { data-slot="session-side-terminal-region" classList={{ "relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true, - "will-change-[height]": !screen.size.active() && store.sideHeightMotion, - "transition-none": screen.size.active() || !store.sideHeightMotion, + "will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(), + "transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(), }} style={{ height: screen.side.terminal.height() }} >
@@ -381,6 +407,7 @@ export function SessionScreen(props: { session: SessionModel }) { fill framed={false} present={store.sideTerminalPresent} + animate={sidePresence.animate() || sideMotion().animateTerminal} contentHeight={screen.side.terminal.contentHeight()} />
@@ -397,7 +424,7 @@ export function SessionScreen(props: { session: SessionModel }) {
setElements("bottomTerminal", element)} data-slot="terminal-panel-presence" - data-opened={bottomTerminalVisible()} + data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined} classList={{ hidden: !bottomTerminalPresence.present(), "relative min-h-0 shrink-0": isDesktop(), @@ -408,19 +435,23 @@ export function SessionScreen(props: { session: SessionModel }) { { screen.size.touch() - layout.terminal.resize(height) + session.layout.view().terminal.resize(height) }} onCollapse={() => session.layout.view().terminal.close()} />
- +
diff --git a/packages/app/src/session/session-layout.ts b/packages/app/src/session/session-layout.ts index 7241704bf63..120938ac56b 100644 --- a/packages/app/src/session/session-layout.ts +++ b/packages/app/src/session/session-layout.ts @@ -5,6 +5,8 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope" import { useWorkspaceLocation } from "@/workspaces/location" import { useServerSDK } from "@/runtime/server/client" import { base64Encode } from "@opencode-ai/util/encode" +import { ServerConnection } from "@/runtime/server/registry" +import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs" export const useSessionKey = () => { const params = useParams() @@ -19,12 +21,32 @@ export const useSessionKey = () => { export const useSessionLayout = () => { const layout = useLayout() + const tabs = useTabs() const { params, sessionKey, workspaceKey } = useSessionKey() + const serverSDK = useServerSDK() + const currentTab = createMemo(() => { + if (!params.id) return + return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id) + }) + const panes = { + terminalOpened: () => tabs.pane(currentTab(), "terminal"), + setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened), + terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"), + setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height), + reviewOpened: () => tabs.pane(currentTab(), "review"), + setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened), + sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"), + setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width), + } return { params, sessionKey, workspaceKey, + tabKey: createMemo(() => { + const tab = currentTab() + return tab && tabKey(tab) + }), tabs: createMemo(() => layout.tabs(sessionKey)), - view: createMemo(() => layout.view(sessionKey)), + view: createMemo(() => layout.view(sessionKey, panes)), } } diff --git a/packages/app/src/session/terminal/panel.tsx b/packages/app/src/session/terminal/panel.tsx index 40b7828669a..635e7d759c8 100644 --- a/packages/app/src/session/terminal/panel.tsx +++ b/packages/app/src/session/terminal/panel.tsx @@ -17,7 +17,6 @@ import { SortableTerminalTab } from "@/session/terminal/tab" import { Terminal } from "@/session/terminal/terminal" import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" -import { useLayout } from "@/shell/state/layout" import { useTerminal, type LocalPTY } from "@/session/terminal/context" import { useWorkspaceLocation } from "@/workspaces/location" import { terminalTabLabel } from "@/session/terminal/terminal-label" @@ -45,9 +44,9 @@ export function TerminalPanel( present?: boolean contentHeight?: string embedded?: boolean + animate?: boolean } = {}, ) { - const layout = useLayout() const terminal = useTerminal() const sdk = useWorkspaceLocation() const language = useLanguage() @@ -57,7 +56,7 @@ export function TerminalPanel( const isDesktop = createMediaQuery("(min-width: 768px)") const opened = createMemo(() => view().terminal.opened()) const size = createSizing() - const height = createMemo(() => layout.terminal.height()) + const height = createMemo(() => view().terminal.height()) const close = () => view().terminal.close() let root: HTMLElement | undefined let tabList: HTMLDivElement | undefined @@ -238,10 +237,11 @@ export function TerminalPanel( pane={pane()} max={max()} resizing={size.active()} + animate={props.animate} onResizeStart={size.start} onResize={(next) => { size.touch() - layout.terminal.resize(next) + view().terminal.resize(next) }} onCollapse={close} > diff --git a/packages/app/src/session/terminal/surface.tsx b/packages/app/src/session/terminal/surface.tsx index f67c6c7c169..80c0f36d2b7 100644 --- a/packages/app/src/session/terminal/surface.tsx +++ b/packages/app/src/session/terminal/surface.tsx @@ -15,6 +15,7 @@ export function TerminalSurface( pane: number max: number resizing: boolean + animate?: boolean onResizeStart: () => void onResize: (height: number) => void onCollapse: () => void @@ -27,7 +28,9 @@ export function TerminalSurface( id="terminal-panel" data-component="terminal-panel" data-opened={props.opened} - data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)} + data-size-animated={ + props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked) + } role="region" aria-label={props.label} aria-hidden={!props.opened} diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index a962ca23808..8f0efd21c4b 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -59,6 +59,16 @@ export type HomeProjectSelection = { server: ServerConnection.Key; directory?: s export type ReviewDiffStyle = "unified" | "split" export type ReviewChangeMode = "git" | "branch" | "turn" export type ReviewPanelSource = "context-button" | "other" +export type TabPanes = { + terminalOpened: Accessor + setTerminalOpened(opened: boolean): void + terminalHeight: Accessor + setTerminalHeight(height: number): void + reviewOpened: Accessor + setReviewOpened(opened: boolean): void + sessionWidth: Accessor + setSessionWidth(width: number): void +} export type LayoutRoute = | { type: "home" } @@ -508,7 +518,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return message }, }, - view(sessionKey: string | Accessor) { + view(sessionKey: string | Accessor, panes?: TabPanes) { const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const reviewMode = createMemo(() => { @@ -519,11 +529,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const file = s().reviewFile if (typeof file === "string") return file }) - const terminalOpened = createMemo(() => store.terminal?.opened ?? false) - const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) + const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false) + const terminalHeight = createMemo(() => + panes + ? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT) + : (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT), + ) + const reviewPanelOpened = + panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) + const sessionWidth = createMemo(() => + panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width, + ) const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other")) function setTerminalOpened(next: boolean) { + if (panes) { + panes.setTerminalOpened(next) + return + } const current = store.terminal if (!current) { setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next }) @@ -537,6 +560,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) { const nextSource = next ? source : "other" + if (panes) { + batch(() => { + panes.setReviewOpened(next) + setEphemeral("reviewPanelSource", nextSource) + }) + return + } const current = store.review if (!current) { batch(() => { @@ -566,6 +596,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, terminal: { opened: terminalOpened, + height: terminalHeight, + resize(height: number) { + if (panes) { + panes.setTerminalHeight(height) + return + } + setStore("terminal", "height", height) + }, open() { setTerminalOpened(true) }, @@ -579,6 +617,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( reviewPanel: { opened: reviewPanelOpened, source: reviewPanelSource, + width: sessionWidth, + resize(width: number) { + if (panes) { + panes.setSessionWidth(width) + return + } + setStore("session", "width", width) + }, open(source: ReviewPanelSource = "other") { setReviewPanelOpened(true, source) }, diff --git a/packages/app/src/shell/tabs/tabs.test.ts b/packages/app/src/shell/tabs/tabs.test.ts index 963b620864e..20d4c6c64f5 100644 --- a/packages/app/src/shell/tabs/tabs.test.ts +++ b/packages/app/src/shell/tabs/tabs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed" -import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs" +import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs" import { migrateTabs } from "./migration" import type { ServerConnection } from "@/runtime/server/registry" @@ -48,8 +48,12 @@ test("session tab identity stays rooted while its href follows the child route", }) test("finds open root and routed session tabs", () => { - const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }] + const tab = { ...sessionTab("root"), routeSessionId: "child" } + const tabs = [tab] + expect(findSessionTab(tabs, server, "root")).toBe(tab) + expect(findSessionTab(tabs, server, "child")).toBe(tab) + expect(findSessionTab(tabs, server, "closed")).toBeUndefined() expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true) expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true) expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false) diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index 9297c436d67..c2ffe377b9b 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -46,6 +46,10 @@ export type TabInfo = { directory?: string } +export type TabPane = "terminal" | "review" +export type TabPaneSize = "terminalHeight" | "sessionWidth" +type TabPaneState = Partial & Record> + type RecentTab = { key?: string } @@ -62,8 +66,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses return sessionIDHasOpenTab(tabs, server, session.id) } -export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) { - return tabs.some( +export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) { + return tabs.find( (tab) => tab.type === "session" && tab.server === server && @@ -71,6 +75,10 @@ export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, s ) } +export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) { + return !!findSessionTab(tabs, server, sessionID) +} + export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ name: "Tabs", gate: false, @@ -89,6 +97,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ Persist.window("tabs.info"), createStore>({}), ) + const [panes, setPanes, , panesReady] = persisted( + Persist.window("tabs.panes"), + createStore>({}), + ) const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const [pending, setPending] = createStore>({}) @@ -141,6 +153,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ ) } + const removePanes = (key: string) => { + if (!panes[key]) return + setPanes( + produce((draft) => { + delete draft[key] + }), + ) + } + onCleanup(memory.dispose) createEffect(() => { @@ -153,6 +174,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const key = tabKey(tab) memory.remove(key) removeInfo(key) + removePanes(key) } } setStore(() => next) @@ -162,6 +184,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ for (const key of Object.keys(info)) { if (!keys.has(key)) removeInfo(key) } + if (!panesReady()) return + for (const key of Object.keys(panes)) { + if (!keys.has(key)) removePanes(key) + } }) createEffect(() => { @@ -198,6 +224,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }).finally(() => closing.delete(key)) memory.remove(key) removeInfo(key) + removePanes(key) if (draftID) removeDraftPersisted(draftID) } @@ -491,8 +518,38 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ stateValue(tab: Tab, name: string) { return memory.get(tabKey(tab), name) }, + pane(tab: Tab | undefined, pane: TabPane) { + if (!tab) return false + return panes[tabKey(tab)]?.[pane] ?? false + }, + setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) { + if (!tab) return + const key = tabKey(tab) + const current = panes[key] + if (current?.[pane] === opened) return + if (!current) { + setPanes(key, { [pane]: opened }) + return + } + setPanes(key, pane, opened) + }, + paneSize(tab: Tab | undefined, size: TabPaneSize) { + if (!tab) return + return panes[tabKey(tab)]?.[size] + }, + setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) { + if (!tab) return + const key = tabKey(tab) + const current = panes[key] + if (current?.[size] === value) return + if (!current) { + setPanes(key, { [size]: value }) + return + } + setPanes(key, size, value) + }, } - return { ...actions, store, info, ready, infoReady, recentReady } + return { ...actions, store, info, ready, infoReady, recentReady, panesReady } }, }) diff --git a/packages/app/test-browser/animated-presence-state.test.ts b/packages/app/test-browser/animated-presence-state.test.ts index 481e099c04c..1fc2282cdce 100644 --- a/packages/app/test-browser/animated-presence-state.test.ts +++ b/packages/app/test-browser/animated-presence-state.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { createAnimatedPresence } from "../src/runtime/animated-presence" -import { createRoot, createSignal } from "solid-js" +import { batch, createRoot, createSignal } from "solid-js" test("animates visibility changes without animating initial presence", () => { createRoot((dispose) => { @@ -47,3 +47,22 @@ test("animates the first appearance when initially hidden", () => { dispose() }) }) + +test("does not animate visibility changes across identities", () => { + createRoot((dispose) => { + const [identity, setIdentity] = createSignal("a") + const [value, setValue] = createSignal("visible") + const presence = createAnimatedPresence(value, () => null, identity) + + expect(presence.animate()).toBe(false) + batch(() => { + setIdentity("b") + setValue(undefined) + }) + expect(presence.animate()).toBe(false) + + setValue("visible") + expect(presence.animate()).toBe(true) + dispose() + }) +})