diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index c6dfc3db8..2629d5e3c 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -287,7 +287,7 @@ "packages/app/src/pages/new-session.tsx": "c060bbb15569b6da2f67146e6af0879ccb5444345df03dbc2f9b39cb98b9db78", "packages/app/src/pages/overlay-content-hash.test.ts": "aa208098213522dcde42434408afe9c8641fad663f44a58c316922dbd0da2f5c", "packages/app/src/pages/research-surfaces-absence.test.ts": "9944a8ec5492500eb82548d03b24ea7fb8ccc9c2ae5de14a4d18ddb309378746", - "packages/app/src/pages/session.tsx": "7918f8bed4b1bd0932e5e6681546217fa24e5b6e81667163ef4b63439516328f", + "packages/app/src/pages/session.tsx": "546262aa8537e9f0229d63fbe7c94179db328568709ec27c44d5de9a374086ee", "packages/app/src/utils/amicode-bridge.ts": "3ec87c24c0384d312f7eb644807d9217a53b1b4b97f7fffadab1cc1595603f3b", "packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444", "packages/app/src/utils/amicode-bug-report.ts": "d55d9cecfe17719c0d4a0ca498345c7eff6fca1de6b645e4bc3fecb40f2f4645", @@ -1034,7 +1034,9 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f", "packages/opencode/src/server/routes/instance/httpapi/middleware/file-write-workspace.ts": "665c33633173ee89c6da1d4a0eaacaf4eebe3ccdd04eb36b063515cb4c093e4a", "packages/opencode/src/server/routes/instance/httpapi/middleware/gateway-secret.ts": "ec6133ee5d331920ded81042fbeb05221bec0c4b26f5897b3f28bc267d26aefa", - "packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts": "6ded09e0f424f894e100e1f4904787d97ae1512f25688cfa2f05cbfbd3811a2a" + "packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts": "6ded09e0f424f894e100e1f4904787d97ae1512f25688cfa2f05cbfbd3811a2a", + "packages/app/src/pages/session/work-column-presence.ts": "a6a1485299d602267f91bf821d816f6e97f620fc5e5bb9e946511d111b6fe57e", + "packages/app/src/pages/session/work-column-presence.test.ts": "435ded3491681f9274ee1d4f219ce6191e2f374005271844deb12b1c67cb9f1b" }, "symlinks": [ "packages/app/public/amico.svg" @@ -1098,7 +1100,7 @@ } }, "counts": { - "overlay_total": 1027, + "overlay_total": 1029, "deletions": 7, "server_coupled": 0 }, @@ -2129,7 +2131,9 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/middleware/file-write-workspace.ts": "A", "packages/opencode/src/server/routes/instance/httpapi/middleware/gateway-secret.ts": "A", - "packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts": "M" + "packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts": "M", + "packages/app/src/pages/session/work-column-presence.ts": "A", + "packages/app/src/pages/session/work-column-presence.test.ts": "A" }, "upstream_base_archive_sha256": "4b5f2e5edfc796082a353fd09c1b1dec3e7a108a4b080fe3a86b12d9fa59fae6" } diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx index 44a38aabc..26b7c69a0 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx @@ -86,6 +86,14 @@ import { } from "@/pages/session/session-panel-width" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { sessionPanelLayout } from "@/pages/session/session-panel-layout" +import { + reduceWorkColumnPhase, + WORK_COLUMN_TRANSITION_MS, + workColumnExpanded, + workColumnMounted, + workColumnTransitioning, + type WorkColumnPhase, +} from "@/pages/session/work-column-presence" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" @@ -710,6 +718,39 @@ export default function Page() { const workColumnWidth = createMemo(() => clampWorkColumnWidth({ width: layout.panelColumn.width(), available: sessionPanelAvailable() }), ) + // amicode#1610: the work column animates its width on collapse/restore + // instead of unmounting in one frame. It stays mounted through `closing` + // (width → 0) and `opening` (0 → width); the timers below settle the phase. + const workColumnVisible = createMemo(() => isDesktop() && desktopV2PanelLayout().visible) + const [workColumnPhase, setWorkColumnPhase] = createSignal( + untrack(workColumnVisible) ? "open" : "unmounted", + ) + createEffect( + on( + workColumnVisible, + (visible, previous) => { + if (previous === undefined || visible === previous) return + setWorkColumnPhase((phase) => reduceWorkColumnPhase(phase, visible ? "show" : "hide")) + }, + { defer: true }, + ), + ) + createEffect(() => { + const phase = workColumnPhase() + if (!workColumnTransitioning(phase)) return + let live = true + const settle = () => { + if (live) setWorkColumnPhase((current) => reduceWorkColumnPhase(current, "settled")) + } + // closing: wait out the width transition. opening: let the collapsed + // width paint first (two frames), then expand so the transition runs. + const timer = phase === "closing" ? setTimeout(settle, WORK_COLUMN_TRANSITION_MS) : undefined + if (phase === "opening") requestAnimationFrame(() => requestAnimationFrame(settle)) + onCleanup(() => { + live = false + if (timer !== undefined) clearTimeout(timer) + }) + }) const sessionPanelWidth = createMemo(() => { if (chatTakesRemainder()) return undefined if (!desktopSidePanelOpen()) return "100%" @@ -2846,14 +2887,25 @@ export default function Page() { - +
{/* amicode#105: the work column's own resize handle (its left edge), sizing layout.panelColumn within the policy bounds — diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.test.ts new file mode 100644 index 000000000..6a9e71c61 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { + reduceWorkColumnPhase, + WORK_COLUMN_TRANSITION_EASING, + WORK_COLUMN_TRANSITION_MS, + workColumnExpanded, + workColumnMounted, + workColumnTransitioning, + type WorkColumnPhase, +} from "./work-column-presence" + +describe("reduceWorkColumnPhase", () => { + test("collapse animates through closing before unmounting", () => { + let phase: WorkColumnPhase = "open" + phase = reduceWorkColumnPhase(phase, "hide") + expect(phase).toBe("closing") + expect(workColumnMounted(phase)).toBe(true) + expect(workColumnExpanded(phase)).toBe(false) + phase = reduceWorkColumnPhase(phase, "settled") + expect(phase).toBe("unmounted") + expect(workColumnMounted(phase)).toBe(false) + }) + + test("restore mounts collapsed, then expands once settled", () => { + let phase: WorkColumnPhase = "unmounted" + phase = reduceWorkColumnPhase(phase, "show") + expect(phase).toBe("opening") + expect(workColumnMounted(phase)).toBe(true) + expect(workColumnExpanded(phase)).toBe(false) + phase = reduceWorkColumnPhase(phase, "settled") + expect(phase).toBe("open") + expect(workColumnExpanded(phase)).toBe(true) + }) + + test("reversing mid-animation keeps the column mounted and retargets", () => { + expect(reduceWorkColumnPhase("opening", "hide")).toBe("closing") + expect(reduceWorkColumnPhase("closing", "show")).toBe("opening") + }) + + test("events that do not apply to the current phase are no-ops", () => { + expect(reduceWorkColumnPhase("open", "show")).toBe("open") + expect(reduceWorkColumnPhase("unmounted", "hide")).toBe("unmounted") + expect(reduceWorkColumnPhase("unmounted", "settled")).toBe("unmounted") + expect(reduceWorkColumnPhase("open", "settled")).toBe("open") + }) +}) + +describe("transition constants", () => { + test("uses a ~200ms ease-out style curve, not linear", () => { + expect(WORK_COLUMN_TRANSITION_MS).toBe(200) + expect(WORK_COLUMN_TRANSITION_EASING).toBe("cubic-bezier(0.22,1,0.36,1)") + }) +}) + +describe("workColumnTransitioning", () => { + test("is true only while opening or closing", () => { + expect(workColumnTransitioning("opening")).toBe(true) + expect(workColumnTransitioning("closing")).toBe(true) + expect(workColumnTransitioning("open")).toBe(false) + expect(workColumnTransitioning("unmounted")).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.ts new file mode 100644 index 000000000..bfcebd8fa --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.ts @@ -0,0 +1,42 @@ +// Presence state for the Work Column's collapse/restore animation (#1610). +// The column is kept mounted while its width animates to 0 (closing) or from 0 +// (opening), then unmounted (or expanded) once the transition settles. Pure +// reducer: the Solid wiring (timers, frames) lives in session.tsx. + +export const WORK_COLUMN_TRANSITION_MS = 200 +export const WORK_COLUMN_TRANSITION_EASING = "cubic-bezier(0.22,1,0.36,1)" + +export type WorkColumnPhase = "unmounted" | "opening" | "open" | "closing" +export type WorkColumnEvent = "show" | "hide" | "settled" + +export function reduceWorkColumnPhase(phase: WorkColumnPhase, event: WorkColumnEvent): WorkColumnPhase { + switch (phase) { + case "unmounted": + return event === "show" ? "opening" : phase + case "opening": + if (event === "hide") return "closing" + if (event === "settled") return "open" + return phase + case "open": + return event === "hide" ? "closing" : phase + case "closing": + if (event === "show") return "opening" + if (event === "settled") return "unmounted" + return phase + } +} + +/** The column stays in the DOM for every phase except fully collapsed. */ +export function workColumnMounted(phase: WorkColumnPhase): boolean { + return phase !== "unmounted" +} + +/** Only the settled open state renders at the full column width; opening + * starts at 0 so the width transition has a start point, closing ends at 0. */ +export function workColumnExpanded(phase: WorkColumnPhase): boolean { + return phase === "open" +} + +export function workColumnTransitioning(phase: WorkColumnPhase): boolean { + return phase === "opening" || phase === "closing" +}