From 3ba2705a019ae853c9e86dbc21b581a052fb6607 Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Thu, 29 Oct 2026 08:56:00 +0000 Subject: [PATCH 1/2] feat(app): work column collapse/restore presence state machine (harmoniqs/amicode#1610) --- .../session/work-column-presence.test.ts | 62 +++++++++++++++++++ .../src/pages/session/work-column-presence.ts | 42 +++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/work-column-presence.ts 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" +} From be49b028124764cb778b74823c50d008cf3be835 Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Thu, 29 Oct 2026 08:57:00 +0000 Subject: [PATCH 2/2] feat(app): animate work column collapse/restore width (harmoniqs/amicode#1610) Keep the Work Column mounted through a 200ms width transition on collapse (to 0) and restore (from 0), instead of unmounting in one frame. Transition is disabled during manual drag. Manifest hashes patched for the touched and new overlay files. --- packages/app-bundle/manifest.json | 12 ++-- .../packages/app/src/pages/session.tsx | 56 ++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) 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 —