Skip to content

Commit d1e9452

Browse files
committed
feat(ui): animate side-panel slide in/out instead of instant unmount
Panel toggle previously unmounted the aside in a single React commit, so the center column snapped to its new width with no visual continuity (per user feedback: "整个左右两侧被砍掉似的"). Add a 250ms width transition driven by a new `useSlidingPanel` hook in App.tsx and a matching `.is-collapsed` CSS state in global.css. Behavior: - Hide: apply `is-collapsed` (panel width 320→0 animated), then unmount the aside after 250ms so Explorer / RightPanel stop firing IPC + event subs while invisible. - Show: mount the aside in the collapsed state, then drop the class on the next two rAFs so CSS animates 0→320. Double rAF is needed so the browser commits a paint at width 0 before the class flip; otherwise the transition has no "from" frame and snaps open. - Initial render skips the effect entirely so a launch-hidden panel stays unmounted and a launch-visible panel doesn't fake-collapse. Center column's `flex: 1` smoothly reclaims/yields the freed space as the panel width animates, so the OS window stays put and the visible resize is the panel itself, not the whole app. Also drops the stale "side panels chop the window edge" comment block in global.css — the chop behavior was removed in f903be1; the comment was just lying around.
1 parent 99d3d7c commit d1e9452

2 files changed

Lines changed: 120 additions & 22 deletions

File tree

src-ui/src/App.tsx

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// App.tsx — 3-panel IDE layout (frameless window)
22

3-
import { useEffect } from 'react';
3+
import { useEffect, useRef, useState } from 'react';
44
import { useAppState, useAppDispatch } from './store/app-state';
55
import { retryInvoke } from './tauri';
66
import { subscribeAgentStatus } from './lib/agent-status-bus';
@@ -14,10 +14,87 @@ import { RightPanel } from './components/right/Compiler';
1414
import { FileStatsProvider } from './lib/file-stats';
1515
import './styles/global.css';
1616

17+
// CSS transition duration on .panel-left / .panel-right in global.css.
18+
// Bumping this here = bump the matching --panel-slide-ms variable too,
19+
// otherwise React unmounts mid-animation and the panel snaps.
20+
const PANEL_SLIDE_MS = 250;
21+
22+
/**
23+
* Drive the slide-open / slide-closed animation for a single side panel.
24+
*
25+
* hidden=true → if currently mounted, apply `is-collapsed` (CSS animates
26+
* width 320→0) then unmount after PANEL_SLIDE_MS so the
27+
* child stops firing IPC + event subs while invisible.
28+
* hidden=false → mount immediately at width 0 (`is-collapsed`), then
29+
* drop the class on the next paint so CSS animates
30+
* 0→320. Two rAFs are needed: one to commit React's
31+
* initial collapsed render, a second to let the browser
32+
* paint at width 0 before the class flip — otherwise the
33+
* transition has no "from" frame and the panel just snaps.
34+
*
35+
* Initial render skips the animation: a panel hidden from launch starts
36+
* unmounted with no flicker; a visible-by-default panel renders at full
37+
* width with no fake collapse-then-expand.
38+
*/
39+
function useSlidingPanel(hidden: boolean): { mounted: boolean; collapsed: boolean } {
40+
const [mounted, setMounted] = useState(!hidden);
41+
const [collapsed, setCollapsed] = useState(false);
42+
const isFirstRun = useRef(true);
43+
const timeoutRef = useRef<number | null>(null);
44+
const rafRef = useRef<number | null>(null);
45+
46+
useEffect(() => {
47+
if (isFirstRun.current) {
48+
isFirstRun.current = false;
49+
return;
50+
}
51+
if (timeoutRef.current !== null) {
52+
clearTimeout(timeoutRef.current);
53+
timeoutRef.current = null;
54+
}
55+
if (rafRef.current !== null) {
56+
cancelAnimationFrame(rafRef.current);
57+
rafRef.current = null;
58+
}
59+
if (hidden) {
60+
setCollapsed(true);
61+
timeoutRef.current = window.setTimeout(() => {
62+
setMounted(false);
63+
setCollapsed(false);
64+
timeoutRef.current = null;
65+
}, PANEL_SLIDE_MS);
66+
} else {
67+
setMounted(true);
68+
setCollapsed(true);
69+
rafRef.current = requestAnimationFrame(() => {
70+
rafRef.current = requestAnimationFrame(() => {
71+
setCollapsed(false);
72+
rafRef.current = null;
73+
});
74+
});
75+
}
76+
return () => {
77+
if (timeoutRef.current !== null) {
78+
clearTimeout(timeoutRef.current);
79+
timeoutRef.current = null;
80+
}
81+
if (rafRef.current !== null) {
82+
cancelAnimationFrame(rafRef.current);
83+
rafRef.current = null;
84+
}
85+
};
86+
}, [hidden]);
87+
88+
return { mounted, collapsed };
89+
}
90+
1791
export function App() {
1892
const { state } = useAppState();
1993
const dispatch = useAppDispatch();
2094

95+
const leftPanel = useSlidingPanel(state.leftPanelHidden);
96+
const rightPanel = useSlidingPanel(state.rightPanelHidden);
97+
2198
// Subscribe to hook-driven agent status events from each AI CLI.
2299
// The Rust hook server emits these as they arrive from the per-tool
23100
// forwarder script (Python for Claude / Codex, JS for OpenCode).
@@ -140,18 +217,20 @@ export function App() {
140217
<TitleBar />
141218

142219
{/* 3-panel workspace. Titlebar toggles flip leftPanelHidden /
143-
rightPanelHidden; the hidden panel is conditionally UNMOUNTED
144-
(Explorer / RightPanel stop firing IPC, scans, event subs,
145-
reconciliation) and the center column's `flex: 1` reclaims
146-
the freed space. The OS window itself doesn't resize — same
147-
model as VS Code / Cursor / Warp — which keeps the toggle
148-
flicker-free (a previous version moved the window edge via
149-
Tauri setSize, but the IPC landed a few frames after the
150-
React commit and made the center column visibly bounce). */}
220+
rightPanelHidden. The OS window itself doesn't resize (same
221+
model as VS Code / Cursor / Warp) — toggling just collapses
222+
the panel's width to 0 over a 250ms CSS transition while the
223+
center column's `flex: 1` smoothly reclaims the freed space.
224+
Once the slide-out animation completes the panel fully
225+
UNMOUNTS so Explorer / RightPanel stop firing IPC + event
226+
subs while hidden; on show, we mount in the collapsed state
227+
and let CSS animate it back open. */}
151228
<FileStatsProvider>
152229
<div className="app-layout">
153-
{!state.leftPanelHidden && (
154-
<aside className="panel panel-left">
230+
{leftPanel.mounted && (
231+
<aside
232+
className={`panel panel-left${leftPanel.collapsed ? ' is-collapsed' : ''}`}
233+
>
155234
<Explorer />
156235
</aside>
157236
)}
@@ -161,8 +240,10 @@ export function App() {
161240
<CenterPanel />
162241
</main>
163242

164-
{!state.rightPanelHidden && (
165-
<aside className="panel panel-right">
243+
{rightPanel.mounted && (
244+
<aside
245+
className={`panel panel-right${rightPanel.collapsed ? ' is-collapsed' : ''}`}
246+
>
166247
<RightPanel />
167248
</aside>
168249
)}

src-ui/src/styles/global.css

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -696,14 +696,12 @@ body {
696696
color: #ffffff;
697697
}
698698

699-
/* Hidden panels are unmounted in App.tsx (no DOM node, so no flex
700-
slot to compensate for). The titlebar toggle ALSO calls Tauri
701-
setSize/setPosition to shrink the OS window by the panel's width
702-
on the same axis — so the right edge (for right-panel hide) or
703-
left edge (for left-panel hide) of the window physically moves
704-
inward, "chopping off" that side. Net effect: center column stays
705-
the same pixel width, min/max/close ride the new edge inward,
706-
no empty gutter, no xterm refit. See TitleBar.tsx. */
699+
/* Side panels (.panel-left / .panel-right) slide in/out via a CSS
700+
width transition driven by the `.is-collapsed` class in App.tsx.
701+
useSlidingPanel keeps the panel mounted during the 250ms collapse
702+
animation, then unmounts it so Explorer / RightPanel stop firing
703+
IPC + event subs while invisible. The Tauri OS window itself never
704+
resizes on toggle — same model as VS Code / Cursor / Warp. */
707705

708706
.app-layout {
709707
display: flex;
@@ -758,6 +756,14 @@ body {
758756
width: var(--w-left);
759757
min-width: var(--w-left);
760758
flex-shrink: 0;
759+
/* `width` + `min-width` are both animated on toggle; transition
760+
duration must match PANEL_SLIDE_MS in App.tsx — bump them
761+
together so React doesn't unmount mid-animation. `overflow:
762+
hidden` keeps Explorer's content from spilling visibly while
763+
the panel slides shut. */
764+
overflow: hidden;
765+
transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1),
766+
min-width 250ms cubic-bezier(0.4, 0, 0.2, 1);
761767
/* IDE-style framing: side panels share --bg-panel (slightly lighter
762768
in every theme), center workspace stays at --bg-app / --bg-terminal
763769
(slightly darker), so the terminal reads as the focal "recessed"
@@ -769,6 +775,10 @@ body {
769775
backdrop-filter: var(--glass-blur);
770776
-webkit-backdrop-filter: var(--glass-blur);
771777
}
778+
.panel-left.is-collapsed {
779+
width: 0;
780+
min-width: 0;
781+
}
772782

773783
/* Explorer panel-content: overflow hidden so welcome state flex-center works */
774784
.explorer-content {
@@ -963,14 +973,21 @@ body.gambit-docked .chat-reader-container {
963973
flex-shrink: 0;
964974
display: flex;
965975
flex-direction: column;
976+
/* Slide-animation match for .panel-left — see comment there. */
977+
overflow: hidden;
978+
transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1),
979+
min-width 250ms cubic-bezier(0.4, 0, 0.2, 1);
966980
/* Same --bg-panel as .panel-left (see comment there) — sandwich
967981
the darker terminal/launchpad center between two brighter rails. */
968982
background: var(--bg-panel);
969983
backdrop-filter: none;
970984
-webkit-backdrop-filter: none;
971985
box-shadow: none;
972986
border-radius: 0;
973-
overflow: visible;
987+
}
988+
.panel-right.is-collapsed {
989+
width: 0;
990+
min-width: 0;
974991
}
975992

976993
.compiler-top {

0 commit comments

Comments
 (0)