fix(app): preserve session panes across navigation (#44477)

This commit is contained in:
Brendan Allan
2026-08-24 01:37:24 +08:00
committed by GitHub
parent e012a57d1d
commit be6abc02b5
13 changed files with 923 additions and 184 deletions
@@ -179,6 +179,7 @@ async function expectMountedTree(page: Page, total: number) {
}
async function expectSideGeometry(page: Page) {
await expectPanelGap(page, 8)
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
@@ -188,15 +189,20 @@ async function expectSideGeometry(page: Page) {
terminalLeft: terminal.left,
terminalRight: terminal.right,
terminalTop: terminal.top,
terminalBottom: terminal.bottom,
reviewTop: review.top,
reviewBottom: review.bottom,
}
})
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
}
async function expectBottomGeometry(page: Page) {
await expectPanelGap(page, 8)
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
@@ -226,6 +232,30 @@ async function expectBottomGeometry(page: Page) {
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
}
async function expectPanelGap(page: Page, expected: number) {
await expect
.poll(() => {
return page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.NEGATIVE_INFINITY
const gap = terminal.top - review.bottom
return gap
})
})
.toBeGreaterThanOrEqual(expected - 1)
await expect
.poll(() => {
return page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.POSITIVE_INFINITY
return terminal.top - review.bottom
})
})
.toBeLessThanOrEqual(expected + 1)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -243,7 +243,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
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)
const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`)
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
function seedCachedTerminal(page: Page) {
@@ -1,4 +1,4 @@
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"
@@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -42,6 +42,16 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/animation.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/pty*", (route) =>
@@ -94,24 +104,403 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await installMotionProbe(page)
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeVisible()
await expectWidthMotions(page, 1)
await expectReviewWidthStable(page)
await expectLogicalSideAlignment(page, "ltr")
await page.evaluate(() => (document.documentElement.dir = "rtl"))
await expectLogicalSideAlignment(page, "rtl")
await page.evaluate(() => (document.documentElement.dir = "ltr"))
await page.keyboard.press("Control+Backquote")
const panel = page.locator("#terminal-panel")
const terminalContent = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(terminalContent).toBeVisible()
await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await expectHeightMotions(page, "session-side-region", 1)
await expectHeightMotions(page, "session-side-terminal-region", 1)
await expectStackedGeometry(page)
await expectPanelGapHeld(page)
await resetTerminalTopMotion(page)
await resetTerminalBottomMotion(page)
await resetTerminalAnchorGaps(page)
await resetPanelGaps(page)
await reviewToggle.click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(panel).toBeVisible()
await expectHeightMotions(page, "session-side-region", 2)
await expectHeightMotions(page, "session-side-terminal-region", 2)
await expectTerminalTopMotion(page)
await expectTerminalBottomFixed(page)
await expectTerminalTopAnchored(page)
await expectPanelGapHeld(page)
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeVisible()
await expectHeightMotions(page, "session-side-region", 3)
await expectHeightMotions(page, "session-side-terminal-region", 3)
await resetTerminalContentSizes(page)
await resetPanelGaps(page)
await page.keyboard.press("Control+Backquote")
await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip")
await expectHeightMotions(page, "session-side-region", 4)
await expectHeightMotions(page, "session-side-terminal-region", 4)
await expect(panel).toBeHidden()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectTerminalContentCachedSize(page)
await expectStackPainted(page)
await expectPanelGapHeld(page)
await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px")
await reviewToggle.click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await expectWidthMotions(page, 2)
await resetHeightMotions(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await expectWidthMotions(page, 3)
await expectSideMotionSettled(page)
await expectNoHeightMotion(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveCount(0)
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await expect(panel).toBeHidden()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectWidthMotions(page, 4)
await page.setViewportSize({ width: 1200, height: 700 })
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await expect(terminalContent).toBeVisible()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectWidthMotions(page, 5)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeHidden()
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
)
})
await page.reload()
await expectSessionTitle(page, title)
await installMotionProbe(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expectAnimation(page, "terminal-panel-size-in")
await page.keyboard.press("Control+Backquote")
await expectAnimation(page, "terminal-panel-size-out")
await expect(panel).toBeHidden()
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
})
type MotionProbe = {
widths: number
reviewWidths: number[]
paintGaps: { review: number; terminalSurface: number }[]
terminalContentSizes: { width: number; height: number }[]
terminalAnchorGaps: number[]
resetAnchorOnMotion: boolean
panelGaps: number[]
terminalTops: number[]
terminalBottoms: number[]
heights: string[]
animations: string[]
}
async function installMotionProbe(page: Page) {
await page.evaluate(() => {
const probe: MotionProbe = {
widths: 0,
reviewWidths: [],
paintGaps: [],
terminalContentSizes: [],
terminalAnchorGaps: [],
resetAnchorOnMotion: false,
panelGaps: [],
terminalTops: [],
terminalBottoms: [],
heights: [],
animations: [],
}
const observed = new WeakSet<Element>()
const observers: ResizeObserver[] = []
const observeReview = () => {
const review = document.querySelector('[data-component="session-review-v2"]')
if (!review || observed.has(review)) return
observed.add(review)
const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width))
observer.observe(review)
observers.push(observer)
}
const observedRegions = new WeakSet<Element>()
const observeStack = () => {
const reviewRegion = document.querySelector<HTMLElement>('[data-slot="session-side-region"]')
const terminalRegion = document.querySelector<HTMLElement>('[data-slot="session-side-terminal-region"]')
if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return
observedRegions.add(reviewRegion)
const observer = new ResizeObserver(() => {
const review = document.querySelector<HTMLElement>("#review-panel")
const terminal = document.querySelector<HTMLElement>("#terminal-panel")
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
if (!terminal || !terminalContent) return
probe.terminalTops.push(terminal.getBoundingClientRect().top)
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
probe.terminalContentSizes.push({
width: terminalContent.getBoundingClientRect().width,
height: terminalContent.getBoundingClientRect().height,
})
const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top)
if (probe.resetAnchorOnMotion) {
if (anchorGap > 8) return
probe.terminalAnchorGaps = []
probe.resetAnchorOnMotion = false
}
probe.terminalAnchorGaps.push(anchorGap)
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
if (!review) return
probe.paintGaps.push({
review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height),
terminalSurface: Math.abs(
terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height,
),
})
})
observer.observe(reviewRegion)
observer.observe(terminalRegion)
observers.push(observer)
}
new MutationObserver(() => {
observeReview()
observeStack()
}).observe(document.body, { childList: true, subtree: true })
observeReview()
observeStack()
document.addEventListener("transitionrun", (event) => {
if (!(event.target instanceof Element)) return
const slot = event.target.getAttribute("data-slot")
if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++
if (event.propertyName === "height" && slot) {
probe.heights.push(slot)
}
})
document.addEventListener("animationstart", (event) => {
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
probe.animations.push(event.animationName)
})
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
})
}
async function expectWidthMotions(page: Page, count: number) {
await expect
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0))
.toBeGreaterThanOrEqual(count)
}
async function resetHeightMotions(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.heights = []
})
}
async function expectSideMotionSettled(page: Page) {
const side = page.locator('[data-slot="session-side-panel-presence"]')
await expect
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
.toBe(true)
}
async function expectNoHeightMotion(page: Page) {
const heights = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [],
)
expect(heights).toEqual([])
}
async function expectReviewWidthStable(page: Page) {
const side = page.locator('[data-slot="session-side-panel-presence"]')
await expect
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
.toBe(true)
await expect
.poll(() =>
page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0),
)
.toBeGreaterThan(0)
const widths = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [],
)
expect(new Set(widths).size).toBe(1)
}
async function expectStackedGeometry(page: Page) {
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.POSITIVE_INFINITY
return terminal.top - review.bottom
}),
)
.toBeLessThanOrEqual(9)
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.NEGATIVE_INFINITY
return terminal.top - review.bottom
}),
)
.toBeGreaterThanOrEqual(7)
}
async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") {
await expect
.poll(() =>
page.evaluate((direction) => {
const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect()
const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect()
if (!frame || !content) return Number.POSITIVE_INFINITY
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
}, direction),
)
.toBeLessThanOrEqual(1)
}
async function expectStackPainted(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1)
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
}
async function resetTerminalTopMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalTops = []
})
}
async function resetTerminalBottomMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalBottoms = []
})
}
async function expectTerminalBottomFixed(page: Page) {
const bottoms = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [],
)
expect(bottoms.length).toBeGreaterThan(0)
expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1)
}
async function resetTerminalAnchorGaps(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.resetAnchorOnMotion = true
})
}
async function resetPanelGaps(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.panelGaps = []
})
}
async function expectPanelGapHeld(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6)
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0)
expect(Math.max(...gaps)).toBeLessThanOrEqual(9)
}
async function expectTerminalTopAnchored(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8)
}
async function resetTerminalContentSizes(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalContentSizes = []
})
}
async function expectTerminalContentCachedSize(page: Page) {
const sizes = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [],
)
expect(sizes.length).toBeGreaterThan(0)
expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100)
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
}
async function expectTerminalTopMotion(page: Page) {
const tops = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
)
const unique = [...new Set(tops)]
const range = Math.max(...unique) - Math.min(...unique)
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
}
async function expectHeightMotions(page: Page, slot: string, count: number) {
await expect
.poll(() =>
page.evaluate(
(slot) =>
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot)
.length ?? 0,
slot,
),
)
.toBeGreaterThanOrEqual(count)
}
async function expectAnimation(page: Page, name: string) {
await expect
.poll(() =>
page.evaluate(
(name) =>
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false,
name,
),
)
.toBe(true)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
+127
View File
@@ -33,6 +33,133 @@
}
@layer components {
[data-slot="session-side-panel-presence"][data-opened="true"] {
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-slot="session-side-panel-presence"][data-opened="false"] {
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
[data-slot="session-side-region-presence"][data-opened="true"] {
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-slot="session-side-region-presence"][data-opened="false"] {
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-slot="terminal-panel-presence"][data-opened="true"] {
animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-slot="terminal-panel-presence"][data-opened="false"] {
animation: terminal-panel-presence-out 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
[data-slot="side-terminal-panel-presence"][data-opened="true"] {
animation: side-terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-slot="side-terminal-panel-presence"][data-opened="false"] {
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
[data-slot="terminal-panel-presence"],
[data-slot="side-terminal-panel-presence"],
[data-slot="session-side-panel-presence"],
[data-slot="session-side-region-presence"],
[data-component="terminal-panel"] {
animation: none !important;
}
}
@keyframes terminal-panel-presence-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes terminal-panel-presence-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes side-terminal-panel-presence-in {
from {
opacity: 0.999999;
}
to {
opacity: 1;
}
}
@keyframes side-terminal-panel-presence-out {
from {
opacity: 1;
}
to {
opacity: 0.999999;
visibility: hidden;
}
}
@keyframes side-region-presence-in {
from {
opacity: 0;
}
0.01% {
opacity: 0.999999;
}
to {
opacity: 1;
}
}
@keyframes side-region-presence-out {
from {
opacity: 1;
}
to {
opacity: 0.999999;
visibility: hidden;
}
}
@keyframes terminal-panel-size-in {
from {
height: 0;
}
to {
height: var(--terminal-panel-height);
}
}
@keyframes terminal-panel-size-out {
from {
height: var(--terminal-panel-height);
}
to {
height: 0;
}
}
[data-component="getting-started"] {
container-type: inline-size;
container-name: getting-started;
@@ -62,7 +62,7 @@ export function SessionSidePanel(props: {
fileBrowserState: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewSnap: boolean
reviewPresent?: boolean
size: Sizing
stacked?: boolean
}) {
@@ -79,6 +79,7 @@ export function SessionSidePanel(props: {
const shown = settings.visibility.fileTree
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent)
const fileOpen = createMemo(
() =>
isDesktop() &&
@@ -88,11 +89,12 @@ export function SessionSidePanel(props: {
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const visible = createMemo(() => reviewVisible() || fileOpen())
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
if (reviewOpen()) return "auto"
if (!visible()) return "0px"
if (reviewVisible()) return "auto"
return `${fileTreeWidth()}px`
})
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
@@ -252,14 +254,14 @@ export function SessionSidePanel(props: {
"h-full min-h-0": props.stacked,
"pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active() && !props.reviewSnap,
"flex-1": reviewOpen(),
!props.size.active(),
"flex-1": reviewVisible(),
}}
style={{ width: panelWidth() }}
>
<Show when={open()}>
<Show when={visible()}>
<div class="size-full flex">
<Show when={reviewOpen()}>
<Show when={reviewVisible()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
<DragDropProvider
+5 -4
View File
@@ -124,11 +124,12 @@ export function createSessionReview(input: {
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
})
}, 100)
onCleanup(
location().event.listen((event) => {
createEffect(() => {
const stop = location().event.listen((event) => {
if (event.type === "filesystem.changed") refresh()
}),
)
})
onCleanup(stop)
})
createEffect(
on(
() => input.screen.review.open() || mobileChanges(),
+2 -2
View File
@@ -58,7 +58,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
)
}
export function SessionDesktopReview(props: { review: SessionReviewModel }) {
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
return (
<Suspense>
<SessionSidePanel
@@ -79,7 +79,7 @@ export function SessionDesktopReview(props: { review: SessionReviewModel }) {
fileBrowserState={props.review.panelState}
activeDiff={props.review.activeFile()}
focusReviewDiff={props.review.focusFile}
reviewSnap={props.review.screen.review.snap()}
reviewPresent={props.present}
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
/>
+13 -19
View File
@@ -2,12 +2,11 @@ import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from
import { useParams } from "@solidjs/router"
import { CommentsProvider } from "@/composer/comments"
import { FileProvider } from "@/workspaces/files/model"
import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location"
import { LocationProvider } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { useNotification } from "@/shell/notifications/notification"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { useData, useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { ServerConnection } from "@/runtime/server/registry"
import { TerminalProvider } from "@/session/terminal/context"
import { useSettingsCommand } from "@/settings/command"
@@ -86,7 +85,7 @@ function ResolvedTargetSessionRoute() {
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value()}>
<LocationProvider directory={value}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
@@ -114,23 +113,18 @@ function SessionStatePanel(props: ParentProps) {
}
function TargetSessionPage() {
const location = useWorkspaceLocation()
const server = useServerSDK()
return (
// Keep workspace-scoped file, prompt, comment, and terminal state alive when
// the user switches between Sessions in the same workspace.
<Show when={`${server.scope}\0${location().directory}`} keyed>
<TerminalProvider>
<FileProvider>
<ComposerPersistenceProvider>
<CommentsProvider>
<SessionPage />
</CommentsProvider>
</ComposerPersistenceProvider>
</FileProvider>
</TerminalProvider>
</Show>
// These providers select their scoped state reactively and retain bounded caches,
// so keep their owners alive while navigating between workspaces on this server.
<TerminalProvider>
<FileProvider>
<ComposerPersistenceProvider>
<CommentsProvider>
<SessionPage />
</CommentsProvider>
</ComposerPersistenceProvider>
</FileProvider>
</TerminalProvider>
)
}
+57 -30
View File
@@ -1,4 +1,5 @@
import { createComputed, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLayout } from "@/shell/state/layout"
import { useSettings } from "@/settings/model"
@@ -14,11 +15,9 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
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())
const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen())
const sideTerminalOpen = createMemo(() => desktopTerminalOpen() && settings.general.terminalPlacement() === "side")
const bottomTerminalOpen = createMemo(
() => desktopTerminalOpen() && settings.general.terminalPlacement() === "bottom",
)
const sideTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "side")
const bottomTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "bottom")
const sideTerminalOpen = createMemo(() => terminalOpen() && sideTerminal())
const fileTreeOpen = createMemo(
() =>
session.isDesktop() &&
@@ -29,14 +28,14 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
)
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
const [rowWidth, setRowWidth] = createSignal<number>()
const [rowSize, setRowSize] = createStore<{ width?: number; height?: number }>({})
let row: HTMLDivElement | undefined
createResizeObserver(
() => row,
({ width }) => setRowWidth(width),
({ width, height }) => setRowSize({ width, height }),
)
const available = createMemo<number | undefined>(() => {
const width = rowWidth()
const width = rowSize.width
if (width === undefined) return undefined
return width - 8
})
@@ -65,24 +64,30 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
files: fileTreeOpen(),
}),
)
const [reviewSnap, setReviewSnap] = createSignal(false)
let reviewFrame: number | undefined
createComputed((previous) => {
const open = reviewOpen()
if (previous === undefined || previous === open) return open
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
setReviewSnap(true)
reviewFrame = requestAnimationFrame(() => {
reviewFrame = undefined
setReviewSnap(false)
})
return open
}, reviewOpen())
onCleanup(() => {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
const [motion, setMotion] = createStore({ gap: panelLayout().stacked, closing: false })
createEffect((previous) => {
const stacked = panelLayout().stacked
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
return stacked
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
const sideHeight = createMemo(() => rowSize.height)
const fullSideHeight = createMemo(() => (sideHeight() === undefined ? "100%" : `${sideHeight()}px`))
const stackedReviewHeight = createMemo(() => {
const height = sideHeight()
if (height === undefined) return `calc(100% - ${terminalPaneHeight()} - 8px)`
return `${Math.max(0, height - terminalPane() - 8)}px`
})
const sideContentWidth = createMemo<string>((previous) => {
const width = available()
if (resizable() && width !== undefined) return `${Math.max(0, width - resizedWidth())}px`
if (fileTreeOpen()) return `${layout.fileTree.width()}px`
return previous
}, "100%")
return {
centered: createMemo(() => session.isDesktop()),
files: { open: fileTreeOpen },
@@ -99,14 +104,36 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
review: {
open: reviewOpen,
panelOpen: reviewPanelOpen,
snap: reviewSnap,
},
side: { layout: panelLayout },
side: {
contentWidth: sideContentWidth,
gap: {
closing: () => motion.closing,
height: createMemo(() => (motion.gap ? "8px" : "0px")),
},
layout: panelLayout,
region: {
height: createMemo(() => {
if (!sideRegionOpen()) return "0px"
if (sideTerminalOpen()) return stackedReviewHeight()
return fullSideHeight()
}),
open: sideRegionOpen,
},
terminal: {
contentHeight: createMemo(() => (sideRegionOpen() ? terminalPaneHeight() : fullSideHeight())),
height: createMemo(() => {
if (!sideTerminalOpen()) return "0px"
if (sideRegionOpen()) return terminalPaneHeight()
return fullSideHeight()
}),
},
},
size,
terminal: {
bottomOpen: bottomTerminalOpen,
inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()),
bottom: bottomTerminal,
open: terminalOpen,
side: sideTerminal,
},
}
}
+151 -47
View File
@@ -1,5 +1,6 @@
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, 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"
@@ -28,7 +29,42 @@ export function SessionScreen(props: { session: SessionModel }) {
const screen = createSessionScreenLayout(session, serverSDK.scope)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
const [store, setStore] = createStore({ deferRender: false })
const [store, setStore] = createStore({
deferRender: false,
bottomTerminalCached: false,
sideHeightMotion: false,
sideRegionPresent: false,
sideReviewPresent: false,
sideTerminalPresent: false,
})
const [elements, setElements] = createStore<{
side?: HTMLDivElement
bottomTerminal?: HTMLDivElement
}>({})
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom()))
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
})
const bottomTerminalPresence = createPresence({
show: bottomTerminalVisible,
element: () => elements.bottomTerminal ?? null,
})
createEffect(() => {
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
if (!sideVisible()) setStore("sideHeightMotion", false)
})
createEffect(() => {
if (!isDesktop() || screen.terminal.bottom()) setStore("sideTerminalPresent", false)
if (isDesktop() && screen.terminal.side()) setStore("bottomTerminalCached", false)
})
createEffect(() => {
if (screen.side.region.open()) setStore("sideRegionPresent", true)
if (screen.review.panelOpen()) setStore("sideReviewPresent", true)
})
createComputed((prev) => {
const key = session.identity.sessionKey()
@@ -75,15 +111,9 @@ export function SessionScreen(props: { session: SessionModel }) {
</Match>
<Match when={session.identity.params.id}>
<Show when={!messagesReady()}>
<SessionIdentityHeader
sessionID={session.identity.params.id ?? ""}
session={session.data.info()}
/>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
</Show>
<Show
when={messagesReady() ? session.identity.params.id : undefined}
keyed
>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
session={session}
@@ -138,10 +168,11 @@ export function SessionScreen(props: { session: SessionModel }) {
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"@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.review.snap() && !screen.terminal.inlineOnlyOpen(),
!screen.size.active(),
}}
data-slot="session-chat-panel"
style={{
width: screen.panel.width(),
}}
@@ -171,51 +202,124 @@ export function SessionScreen(props: { session: SessionModel }) {
</Show>
</div>
<Show when={isDesktop() && screen.side.layout().visible}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={screen.review.panelOpen() || screen.files.open()}>
<div class="min-h-0 flex-1">
<SessionDesktopReview review={review} />
</div>
</Show>
<Show when={screen.side.layout().stacked}>
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<Show when={screen.terminal.open() && !screen.terminal.bottomOpen()}>
<Show when={sidePresence.present() || store.sideTerminalPresent}>
<div
ref={(element) => setElements("side", element)}
data-slot="session-side-panel-presence"
data-opened={sideVisible()}
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
setStore("sideHeightMotion", true)
}}
classList={{
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sidePresence.present(),
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible":
!sidePresence.present(),
}}
>
<div
data-slot="session-side-panel-content"
class="absolute inset-y-0 start-0 h-full"
style={{ width: screen.side.contentWidth() }}
>
<div
data-slot="session-side-region"
classList={{
"min-h-0 shrink-0": screen.side.layout().stacked,
"min-h-0 flex-1": !screen.side.layout().stacked,
"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,
}}
style={{ height: screen.side.region.height() }}
>
<TerminalPanel stacked={screen.side.layout().stacked} />
<Show when={store.sideRegionPresent}>
<div
data-slot="session-side-region-presence"
data-opened={screen.side.region.open()}
class="absolute inset-0"
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "side-region-presence-out") return
if (screen.side.region.open()) return
setStore("sideRegionPresent", false)
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
</div>
</Show>
</div>
</Show>
<div class="absolute inset-x-0 bottom-0 flex flex-col">
<div
data-slot="session-side-panel-gap"
classList={{
"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(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
>
<Show when={screen.side.layout().stacked}>
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</Show>
</div>
<div
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,
}}
style={{ height: screen.side.terminal.height() }}
>
<Show when={store.sideTerminalPresent}>
<div
data-slot="side-terminal-panel-presence"
data-opened={sideTerminalVisible()}
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
<TerminalPanel
fill
framed={false}
present={store.sideTerminalPresent}
contentHeight={screen.side.terminal.contentHeight()}
/>
</div>
</div>
</Show>
</div>
</div>
</div>
</div>
</Show>
</div>
<Show when={screen.terminal.open() && (!isDesktop() || screen.terminal.bottomOpen())}>
<div classList={{ "relative min-h-0 shrink-0": isDesktop() }}>
<Show when={bottomTerminalPresence.present() || store.bottomTerminalCached}>
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
data-opened={bottomTerminalVisible()}
classList={{
hidden: !bottomTerminalPresence.present(),
"relative min-h-0 shrink-0": isDesktop(),
}}
>
<Show when={isDesktop()}>
<div
class="absolute z-10 -top-1 left-0 right-0 h-2"
onPointerDown={() => screen.size.start()}
>
<div class="absolute z-10 -top-1 left-0 right-0 h-2" onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
@@ -231,7 +335,7 @@ export function SessionScreen(props: { session: SessionModel }) {
/>
</div>
</Show>
<TerminalPanel stacked={isDesktop()} />
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
</div>
</Show>
</div>
+103 -46
View File
@@ -18,7 +18,7 @@ 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 } from "@/session/terminal/context"
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
import { useWorkspaceLocation } from "@/workspaces/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
import { createSizing, focusTerminalById } from "@/session/helpers"
@@ -26,7 +26,20 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { TerminalSurface } from "./surface"
export function TerminalPanel(props: { stacked?: boolean } = {}) {
const MAX_CACHED_TERMINAL_WORKSPACES = 20
type TerminalBinding = ReturnType<ReturnType<typeof useTerminal>["bind"]>
type CachedTerminalSurface = {
key: string
workspace: string
pty: LocalPTY
ops: TerminalBinding
focus: boolean
}
export function TerminalPanel(
props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {},
) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
@@ -45,18 +58,26 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
onCleanup(() => terminal.cancelFocus())
const [store, setStore] = createStore({
autoCreated: false,
autoCreated: undefined as string | undefined,
recovered: {} as Record<string, boolean>,
surfaces: [] as CachedTerminalSurface[],
workspaces: [] as string[],
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
const max = () => store.view * 0.6
const pane = () => Math.min(height(), max())
const stacked = createMemo(() => isDesktop() && !!props.stacked)
const panelHeight = createMemo(() =>
isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
const panelHeight = createMemo(() => {
if (props.fill) return "100%"
if (!opened()) return "0px"
if (isDesktop()) return stacked() ? `${pane()}px` : "100%"
return `${pane()}px`
})
const contentHeight = createMemo(
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`),
)
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
const present = createMemo(() => opened() || !!props.present)
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
onMount(() => {
@@ -68,24 +89,29 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
sync()
makeEventListener(window, "resize", sync)
if (port) makeEventListener(port, "resize", sync)
makeEventListener(document, "focusin", (event) => {
if (event.target instanceof Element && event.target.closest("#terminal-panel")) return
setStore("surfaces", (surface) => surface.focus, "focus", false)
})
})
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
setStore("autoCreated", undefined)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
const workspace = workspaceKey()
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated === workspace) return
terminal.new()
setStore("autoCreated", true)
setStore("autoCreated", workspace)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
() => [workspaceKey(), terminal.all().length] as const,
([workspace, count], previous) => {
if (!previous || previous[0] !== workspace || previous[1] <= 0 || count !== 0) return
if (!opened()) return
close()
},
@@ -97,7 +123,10 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
([next, id, requested]) => {
if (!next || !id || !requested) return
focusTerminalById(id)
requestAnimationFrame(() => {
if (!opened() || terminal.active() !== id || !terminal.focusRequested(id)) return
focusTerminalById(id)
})
},
),
)
@@ -136,19 +165,44 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
const all = terminal.all
createEffect(
on(
() => [workspaceKey(), terminal.ready(), terminal.active(), terminal.all()] as const,
([workspace, ready, active, ptys]) => {
if (!ready) return
const ids = new Set(ptys.map((pty) => pty.id))
const surfaces = store.surfaces.filter((surface) => surface.workspace !== workspace || ids.has(surface.pty.id))
const pty = ptys.find((item) => item.id === active)
const key = pty ? `${workspace}\0${pty.id}` : undefined
if (pty && key && !surfaces.some((surface) => surface.key === key)) {
surfaces.push({ key, workspace, pty, ops: terminal.bind(), focus: terminal.focusRequested(pty.id) })
}
const workspaces = [...store.workspaces.filter((item) => item !== workspace), workspace].slice(
-MAX_CACHED_TERMINAL_WORKSPACES,
)
const keep = new Set(workspaces)
setStore({ surfaces: surfaces.filter((surface) => keep.has(surface.workspace)), workspaces })
},
),
)
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
if (store.recovered[key]) return
setStore("recovered", key, true)
void clone(id)
}
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
return String(pty.titleNumber || pty.title || pty.id)
}
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
setStore("recovered", key, false)
trim(id)
const index = store.surfaces.findIndex((surface) => surface.key === key)
if (!store.surfaces[index]?.focus) return
setStore("surfaces", index, "focus", false)
if (!opened() || terminal.active() !== id) return
focusTerminalById(id)
terminal.consumeFocus(id)
}
const handleTerminalDragEnd = () => {
@@ -167,6 +221,8 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
}}
label={language.t("terminal.title")}
opened={opened()}
present={present()}
framed={props.framed}
desktop={isDesktop()}
stacked={stacked()}
height={panelHeight()}
@@ -182,7 +238,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
onCollapse={close}
>
<Show
when={terminal.ready()}
when={terminal.ready() || store.surfaces.length > 0}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
@@ -268,34 +324,35 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={opened() && terminal.active()} keyed>
{(id) => {
const ops = terminal.bind()
return (
<Show when={all().find((pty) => pty.id === id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
autoFocus={terminal.focusRequested(id)}
onAutoFocus={() => terminal.consumeFocus(id)}
class="!px-[14px]"
onConnect={() =>
markTerminalConnected(terminalRecoveryKey(pty()), id, (terminalID) =>
ops.trim(terminalID),
)
}
onCleanup={(terminal) => ops.update(terminal)}
onConnectError={() =>
recoverTerminal(terminalRecoveryKey(pty()), id, (terminalID) => ops.clone(terminalID))
}
/>
</div>
)}
</Show>
)
}}
</Show>
<For each={store.surfaces}>
{(surface) => (
<div
id={`terminal-wrapper-${surface.pty.id}`}
class="absolute inset-0"
classList={{
hidden:
!present() || surface.workspace !== workspaceKey() || surface.pty.id !== terminal.active(),
}}
>
<Terminal
pty={surface.pty}
autoFocus={terminal.focusRequested(surface.pty.id)}
onAutoFocus={() => {
focusTerminalById(surface.pty.id)
terminal.consumeFocus(surface.pty.id)
}}
class="!px-[14px]"
onConnect={() =>
markTerminalConnected(surface.key, surface.pty.id, (terminalID) => surface.ops.trim(terminalID))
}
onCleanup={(terminal) => surface.ops.update(terminal)}
onConnectError={() =>
recoverTerminal(surface.key, surface.pty.id, (terminalID) => surface.ops.clone(terminalID))
}
/>
</div>
)}
</For>
</div>
</div>
</DragDropProvider>
+12 -7
View File
@@ -5,6 +5,8 @@ export function TerminalSurface(
props: ParentProps<{
label: string
opened: boolean
present?: boolean
framed?: boolean
desktop: boolean
stacked: boolean
height: string
@@ -22,6 +24,9 @@ export function TerminalSurface(
<aside
ref={props.ref}
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={!props.resizing && (!props.desktop || props.stacked)}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -29,13 +34,12 @@ export function TerminalSurface(
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{
"w-full": !props.desktop || props.stacked,
"min-w-0 h-full flex-1": props.desktop && props.opened && !props.stacked,
"w-0 h-full pointer-events-none": props.desktop && !props.opened,
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop,
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!props.desktop && !props.resizing,
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
"will-change-[height]": !props.resizing && (!props.desktop || props.stacked),
}}
style={{ height: props.height }}
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
>
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
<ResizeHandle
@@ -50,7 +54,8 @@ export function TerminalSurface(
/>
</div>
<div
class="absolute inset-0 flex flex-col overflow-hidden"
data-slot="terminal-panel-content"
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
classList={{
"border-t border-border-weak-base": props.opened && !props.desktop,
"pointer-events-none": !props.opened,
+16 -14
View File
@@ -225,20 +225,23 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
},
)
const stop = sdk().event.on("filesystem.changed", (event) => {
invalidateFromWatcher(event, {
normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]),
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
loadFile: (file) => {
void load(file, { force: true })
},
node: tree.node,
isDirLoaded: tree.isLoaded,
refreshDir: (dir) => {
void tree.listDir(dir, { force: true })
},
createEffect(() => {
const stop = sdk().event.on("filesystem.changed", (event) => {
invalidateFromWatcher(event, {
normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]),
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
loadFile: (file) => {
void load(file, { force: true })
},
node: tree.node,
isDirLoaded: tree.isLoaded,
refreshDir: (dir) => {
void tree.listDir(dir, { force: true })
},
})
})
onCleanup(stop)
})
const get = (input: string) => {
@@ -266,7 +269,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
withPath(input, (file) => view().setSelectedLines(file, range))
onCleanup(() => {
stop()
viewCache.clear()
})