Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Run main process test suite
run: CI=1 pnpm --filter main exec vitest run

- name: Run frontend unit tests
run: CI=1 pnpm --filter frontend test

- name: Install Playwright browser dependencies
run: pnpm exec playwright install --with-deps chromium

Expand Down
6 changes: 4 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "tsc -b && vite build && node ../scripts/verify-xterm-request-mode-build.js",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@git-diff-view/react": "^0.0.40",
Expand Down Expand Up @@ -71,6 +72,7 @@
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"typescript-eslint": "^8.19.0",
"vite": "^6.0.6"
"vite": "^6.0.6",
"vitest": "^2.1.9"
}
}
26 changes: 13 additions & 13 deletions frontend/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,15 @@ export const SessionView = memo(() => {
const debouncedPersist = useCallback((sessionId: string, layout: SessionPanelLayout) => {
pendingLayoutRef.current = { sessionId, layout };
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(() => {
persistTimerRef.current = null;
const pending = pendingLayoutRef.current;
if (pending) {
pendingLayoutRef.current = null;
panelApi.setLayout(pending.sessionId, pending.layout).catch(err => {
console.warn('[SessionView] Failed to persist layout:', err);
});
}
}, 500);
}, []);
persistTimerRef.current = setTimeout(flushLayoutPersist, 500);
}, [flushLayoutPersist]);

// Flush a pending layout write before the window closes: the debounce would
// otherwise lose a split made just before quit.
useEffect(() => {
window.addEventListener('beforeunload', flushLayoutPersist);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush layout from the quit path

When the user quits the app via the app-level quit flow, this beforeunload hook is bypassed by the existing shutdown path: main/src/index.ts prevents app.before-quit at line 1200 and later calls app.exit(0) at line 1419 instead of closing the BrowserWindow, so the renderer can be terminated without a beforeunload event. In that split-then-quit scenario the layout can still remain only in pendingLayoutRef; trigger the flush from the main quit flow or explicitly close the window after the pending write has completed.

Useful? React with 👍 / 👎.

return () => window.removeEventListener('beforeunload', flushLayoutPersist);
}, [flushLayoutPersist]);

// --- Layout application helper ---
// Self-healing: every mutation funnels through here, so focus and zoom are
Expand Down Expand Up @@ -793,8 +791,10 @@ export const SessionView = memo(() => {
const updated = removePanelFromLayout(currentLayout.root, panel.id);
if (updated) {
const next: SessionPanelLayout = { ...currentLayout, root: updated };
// Find the next panel in the same group
if (group) {
// Pick a successor only when the closed panel WAS the group's
// active tab; closing a background tab keeps the current one
// (matching VS Code).
if (group && group.activePanelId === panel.id) {
const remainingInGroup = group.panelIds.filter(id => id !== panel.id);
const panelIndex = group.panelIds.indexOf(panel.id);
const nextInGroup = remainingInGroup[Math.min(panelIndex, remainingInGroup.length - 1)];
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/panels/PanelGroupView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export const PanelGroupView: React.FC<PanelGroupViewProps> = React.memo(({
>
{/* Tab strip for secondary groups */}
{!isPrimary && (
<div className="flex-shrink-0 bg-bg-chrome border-b border-border-primary">
<div className="flex-shrink-0 bg-bg-chrome border-b border-border-primary" role="tablist">
<PanelTabStrip
panels={orderedPanels}
activePanelId={group.activePanelId}
Expand Down
48 changes: 46 additions & 2 deletions frontend/src/components/panels/SplitLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Imports allotment/dist/style.css and overrides theme tokens.
*/

import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { Allotment } from 'allotment';
import 'allotment/dist/style.css';
import { PanelGroupView } from './PanelGroupView';
Expand Down Expand Up @@ -122,6 +122,45 @@ export const SplitLayout: React.FC<SplitLayoutProps> = React.memo(({
// Focus chrome only exists once a real split does (pixel-identical rule)
const multiGroup = layout.root.type === 'split';

// Allotment's defaultSizes is mount-only, so when a sibling is added to or
// removed from an existing split (n-ary tab drop), the on-screen
// distribution diverges from the stored model until the next sash drag.
// onChange keeps a live snapshot per split (ref only, no re-render); when a
// split's child count changes, the model syncs from that snapshot once.
const liveSizesRef = useRef(new Map<string, number[]>());
const childCountsRef = useRef(new Map<string, number>());
useEffect(() => {
const changed: string[] = [];
const seen = new Set<string>();
(function walk(node: PanelLayoutNode) {
if (node.type !== 'split') return;
seen.add(node.id);
const prev = childCountsRef.current.get(node.id);
if (prev !== undefined && prev !== node.children.length) changed.push(node.id);
childCountsRef.current.set(node.id, node.children.length);
node.children.forEach(walk);
})(layout.root);
for (const id of Array.from(childCountsRef.current.keys())) {
if (!seen.has(id)) {
childCountsRef.current.delete(id);
liveSizesRef.current.delete(id);
}
}
if (changed.length === 0) return;
// Allotment re-lays out the new pane set after this render; read the
// snapshot on the next tick. The length guard skips stale pre-change
// snapshots if onChange has not fired yet (no sync beats a wrong one).
const timer = setTimeout(() => {
for (const id of changed) {
const sizes = liveSizesRef.current.get(id);
if (sizes && sizes.length === childCountsRef.current.get(id)) {
onSizesChange(id, sizes);
}
}
}, 50);
return () => clearTimeout(timer);
}, [layout.root, onSizesChange]);

// Recursive render
const renderNode = useCallback((node: PanelLayoutNode): React.ReactNode => {
if (node.type === 'group') {
Expand Down Expand Up @@ -154,17 +193,22 @@ export const SplitLayout: React.FC<SplitLayoutProps> = React.memo(({
// Split node. Sizes are persisted on drag end only: onChange fires per
// pointer move (and on zoom show/hide re-layouts), and a store write per
// frame would re-render every group, with live xterm instances inside,
// on each frame of a sash drag.
// on each frame of a sash drag. onChange writes only to a ref, feeding
// the structural-change sync above.
const handleDragEnd = (sizes: number[]) => {
onSizesChange(node.id, sizes);
};
const handleChange = (sizes: number[]) => {
liveSizesRef.current.set(node.id, sizes);
};

return (
<Allotment
key={node.id}
vertical={node.direction === 'column'}
defaultSizes={node.sizes}
proportionalLayout
onChange={handleChange}
onDragEnd={handleDragEnd}
>
{node.children.map(child => {
Expand Down
Loading
Loading