Skip to content

Commit 7683690

Browse files
nedtwiggclaude
andcommitted
Always-visible pane with spawn animation
When the last visible pane is killed or detached, auto-spawn a replacement so the content area is never blank. The replacement receives the removed pane's id as paneToCopy (dead infrastructure for now — future work will copy cwd and terminal kind from it). Split also records paneToCopy → the pane it was split from. Freshly-spawned panes animate in via a clip-path reveal on the dockview group (header + body together). clip-path is used instead of scale so the selection overlay's getBoundingClientRect stays accurate during the animation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bfec4cb commit 7683690

3 files changed

Lines changed: 62 additions & 8 deletions

File tree

docs/specs/layout.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ Dockview's separator borders, sash handles, and groupview borders are all set to
294294
7. **Asymmetric back-navigation**: breadcrumb tracks last direction + origin for opposite-direction return.
295295
8. **Center drop merges panels**: intercepted at group-level `model.onWillDrop` and converted to a swap.
296296
9. **Group drag has null panelId**: falls back to `api.getGroup(groupId).activePanel.id`.
297-
10. **Auto-spawn on empty**: `onDidRemovePanel` creates a new session when the last pane is removed and no doors exist.
297+
10. **Auto-spawn on empty**: `onDidRemovePanel` creates a new session whenever the last visible pane is removed, whether or not doors exist — there is always a pane visible. The new pane receives the just-removed pane's id as `paneToCopy` for future cwd/terminal-kind inheritance.
298298

299299
## Files
300300

lib/src/components/Pond.tsx

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,10 @@ export const RenamingIdContext = createContext<string | null>(null);
432432
export const ZoomedContext = createContext(false);
433433
export const WindowFocusedContext = createContext(true);
434434

435+
// Transient set of pane ids that were just created (split or empty-state auto-spawn).
436+
// TerminalPanel consumes (and removes) its id on first mount to trigger a spawn animation.
437+
export const FreshlySpawnedContext = createContext<Set<string>>(new Set());
438+
435439
const ARROW_OPPOSITES: Record<string, string> = {
436440
ArrowLeft: 'ArrowRight', ArrowRight: 'ArrowLeft',
437441
ArrowUp: 'ArrowDown', ArrowDown: 'ArrowUp',
@@ -459,6 +463,7 @@ function TerminalPanel({ api }: IDockviewPanelProps) {
459463
const selectedId = useContext(SelectedIdContext);
460464
const actions = useContext(PondActionsContext);
461465
const { elements: panelElements, bumpVersion } = useContext(PanelElementsContext);
466+
const freshlySpawned = useContext(FreshlySpawnedContext);
462467
const isFocused = mode === 'passthrough' && selectedId === api.id;
463468
const elRef = useRef<HTMLDivElement>(null);
464469

@@ -472,6 +477,29 @@ function TerminalPanel({ api }: IDockviewPanelProps) {
472477
};
473478
}, [api.id, panelElements, bumpVersion]);
474479

480+
// Freshly spawned: animate the whole dockview group (header + body) as one unit.
481+
// We target api.group.element instead of elRef so the tab header animates too.
482+
// Using clip-path (not transform) is deliberate — transforms would make
483+
// SelectionOverlay's getBoundingClientRect capture the scaled rect and lag
484+
// behind the pane until the animation ends.
485+
useLayoutEffect(() => {
486+
if (!freshlySpawned.has(api.id)) return;
487+
freshlySpawned.delete(api.id);
488+
const groupEl = api.group?.element;
489+
if (!groupEl) return;
490+
groupEl.classList.add('pane-spawning');
491+
const onEnd = (ev: AnimationEvent) => {
492+
if (ev.animationName !== 'pane-spawn') return;
493+
groupEl.classList.remove('pane-spawning');
494+
groupEl.removeEventListener('animationend', onEnd);
495+
};
496+
groupEl.addEventListener('animationend', onEnd);
497+
return () => {
498+
groupEl.removeEventListener('animationend', onEnd);
499+
groupEl.classList.remove('pane-spawning');
500+
};
501+
}, [api, freshlySpawned]);
502+
475503
return (
476504
<div ref={elRef} className="h-full w-full" onMouseDown={() => actions.onClickPanel(api.id)}>
477505
<TerminalPane id={api.id} isFocused={isFocused} />
@@ -975,6 +1003,14 @@ export function Pond({
9751003
return `pane-${(++paneCounterRef.current).toString(36)}-${Math.random().toString(36).substring(2, 7)}`;
9761004
}, []);
9771005

1006+
// newPaneId -> sourcePaneId for panes born from a split or the empty-state auto-spawn.
1007+
// Nothing reads this yet; it exists so future work can copy cwd / terminal kind from the source.
1008+
const paneToCopyByIdRef = useRef(new Map<string, string>());
1009+
1010+
// Ids of panes that were just spawned (split or empty-state auto-spawn). TerminalPanel
1011+
// consumes its id on first mount to play an entrance animation.
1012+
const freshlySpawnedRef = useRef(new Set<string>());
1013+
9781014
// Consumed once in handleReady to restore existing sessions
9791015
const initialPaneIdsRef = useRef(initialPaneIds);
9801016
const restoredLayoutRef = useRef(restoredLayout);
@@ -1292,14 +1328,15 @@ export function Pond({
12921328
}
12931329
});
12941330

1295-
// Auto-create a pane when all panes are killed/detached.
1296-
// Note: this fires synchronously from api.removePanel(). During detachPanel,
1297-
// detachedRef is updated AFTER removePanel returns, so detachedRef.current.length
1298-
// is still 0 here — which is correct: we want a new pane when the last visible
1299-
// pane is detached (the door isn't a pane).
1300-
e.api.onDidRemovePanel(() => {
1301-
if (e.api.totalPanels === 0 && detachedRef.current.length === 0) {
1331+
// Always keep one pane visible: when the last visible pane is removed (killed
1332+
// or detached), spawn a fresh one — regardless of whether doors exist. Carry
1333+
// the just-removed pane's id forward as paneToCopy so future work can copy
1334+
// cwd / terminal kind from it.
1335+
e.api.onDidRemovePanel((removed) => {
1336+
if (e.api.totalPanels === 0) {
13021337
const id = generatePaneId();
1338+
paneToCopyByIdRef.current.set(id, removed.id);
1339+
freshlySpawnedRef.current.add(id);
13031340
e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '<unnamed>' });
13041341
selectPanel(id);
13051342
}
@@ -1744,6 +1781,8 @@ export function Pond({
17441781
if (!api) return;
17451782
const newId = generatePaneId();
17461783
const ref = id && api.getPanel(id) ? id : null;
1784+
if (ref) paneToCopyByIdRef.current.set(newId, ref);
1785+
freshlySpawnedRef.current.add(newId);
17471786
api.addPanel({
17481787
id: newId,
17491788
component: 'terminal',
@@ -1821,6 +1860,7 @@ export function Pond({
18211860
<RenamingIdContext.Provider value={renamingPaneId}>
18221861
<ZoomedContext.Provider value={zoomed}>
18231862
<WindowFocusedContext.Provider value={windowFocused}>
1863+
<FreshlySpawnedContext.Provider value={freshlySpawnedRef.current}>
18241864
<div className="flex-1 min-h-0 flex flex-col bg-surface text-foreground font-sans overflow-hidden">
18251865
{/* Dockview */}
18261866
<div className="flex-1 min-h-0 relative p-1.5">
@@ -1849,6 +1889,7 @@ export function Pond({
18491889
)}
18501890

18511891
</div>
1892+
</FreshlySpawnedContext.Provider>
18521893
</WindowFocusedContext.Provider>
18531894
</ZoomedContext.Provider>
18541895
</RenamingIdContext.Provider>

lib/src/theme.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,16 @@ body.vscode-light {
145145
60% { translate: -3px; }
146146
80% { translate: 2px; }
147147
}
148+
149+
@keyframes pane-spawn {
150+
from { opacity: 0; clip-path: inset(0 9% 18% 9%); }
151+
to { opacity: 1; clip-path: inset(0 0 0 0); }
152+
}
153+
154+
.pane-spawning {
155+
animation: pane-spawn 440ms cubic-bezier(0.22, 1, 0.36, 1);
156+
}
157+
158+
@media (prefers-reduced-motion: reduce) {
159+
.pane-spawning { animation: none; }
160+
}

0 commit comments

Comments
 (0)