Skip to content

Commit bdd5d95

Browse files
linniksaclaude
andcommitted
feat(layout): add flex-stretch panels and focus mode
Task panels now stretch to fill available width using flex-grow instead of fixed pixel sizes. Added a focus mode toggle button in the task title bar that shows only the active task at full width. In focus mode, hidden panels use visibility:hidden (not display:none) so terminals keep their correct dimensions. Focus mode state is persisted across app restarts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a75d0b3 commit bdd5d95

File tree

7 files changed

+93
-22
lines changed

7 files changed

+93
-22
lines changed

src/components/TaskPanel.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
clearPendingAction,
2929
showNotification,
3030
collapseTask,
31+
toggleFocusMode,
3132
} from '../store/store';
3233
import { ResizablePanel, type PanelChild } from './ResizablePanel';
3334
import { EditableText, type EditableTextHandle } from './EditableText';
@@ -344,6 +345,24 @@ export function TaskPanel(props: TaskPanelProps) {
344345
</Show>
345346
</div>
346347
</Show>
348+
<IconButton
349+
icon={
350+
store.focusMode ? (
351+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
352+
<path d="M5.5 2.75A.75.75 0 0 0 4.75 2H2.5a.5.5 0 0 0-.5.5v2.25a.75.75 0 0 0 1.5 0V3.5h1.25a.75.75 0 0 0 .75-.75ZM11.25 2a.75.75 0 0 0 0 1.5H12.5v1.25a.75.75 0 0 0 1.5 0V2.5a.5.5 0 0 0-.5-.5h-2.25ZM3.5 11.25a.75.75 0 0 0-1.5 0V13.5a.5.5 0 0 0 .5.5h2.25a.75.75 0 0 0 0-1.5H3.5v-1.25ZM14 11.25a.75.75 0 0 0-1.5 0v1.25h-1.25a.75.75 0 0 0 0 1.5H13.5a.5.5 0 0 0 .5-.5v-2.25Z" />
353+
</svg>
354+
) : (
355+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
356+
<path d="M2.75 5.5A.75.75 0 0 0 3.5 4.75V3.5h1.25a.75.75 0 0 0 0-1.5H2.5a.5.5 0 0 0-.5.5v2.25c0 .414.336.75.75.75ZM12.5 4.75a.75.75 0 0 0 1.5 0V2.5a.5.5 0 0 0-.5-.5h-2.25a.75.75 0 0 0 0 1.5h1.25v1.25ZM3.5 11.25a.75.75 0 0 0-1.5 0V13.5a.5.5 0 0 0 .5.5h2.25a.75.75 0 0 0 0-1.5H3.5v-1.25ZM13.25 10.5a.75.75 0 0 0-.75.75v1.25h-1.25a.75.75 0 0 0 0 1.5H13.5a.5.5 0 0 0 .5-.5v-2.25a.75.75 0 0 0-.75-.75Z" />
357+
</svg>
358+
)
359+
}
360+
onClick={() => {
361+
if (!store.focusMode) setActiveTask(props.task.id);
362+
toggleFocusMode();
363+
}}
364+
title={store.focusMode ? 'Exit focus mode' : 'Focus on this task'}
365+
/>
347366
<IconButton
348367
icon={
349368
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

src/components/TilingLayout.tsx

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
1-
import { Show, createMemo, createEffect, onMount, onCleanup, ErrorBoundary } from 'solid-js';
1+
import { Show, For, createMemo, createEffect, ErrorBoundary } from 'solid-js';
22
import { store, pickAndAddProject, closeTerminal } from '../store/store';
33
import { closeTask } from '../store/tasks';
4-
import { ResizablePanel, type PanelChild, type ResizablePanelHandle } from './ResizablePanel';
4+
import type { PanelChild } from './ResizablePanel';
55
import { TaskPanel } from './TaskPanel';
66
import { TerminalPanel } from './TerminalPanel';
77
import { NewTaskPlaceholder } from './NewTaskPlaceholder';
8+
import { markDirty } from '../lib/terminalFitManager';
89
import { theme } from '../lib/theme';
910
import { mod } from '../lib/platform';
10-
import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom';
1111

1212
export function TilingLayout() {
1313
let containerRef: HTMLDivElement | undefined;
14-
let panelHandle: ResizablePanelHandle | undefined;
15-
16-
onMount(() => {
17-
if (!containerRef) return;
18-
const handleWheel = createCtrlShiftWheelResizeHandler((deltaPx) => {
19-
panelHandle?.resizeAll(deltaPx);
20-
});
21-
containerRef.addEventListener('wheel', handleWheel, { passive: false });
22-
onCleanup(() => containerRef?.removeEventListener('wheel', handleWheel));
23-
});
2414

2515
// Scroll the active task panel into view when selection changes
2616
createEffect(() => {
@@ -29,6 +19,20 @@ export function TilingLayout() {
2919
const el = containerRef.querySelector<HTMLElement>(`[data-task-id="${CSS.escape(activeId)}"]`);
3020
el?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
3121
});
22+
23+
// When switching tasks in focus mode, mark all terminals of the newly active
24+
// task as dirty so they re-fit to the correct size.
25+
createEffect(() => {
26+
const activeId = store.activeTaskId;
27+
if (!store.focusMode || !activeId) return;
28+
const task = store.tasks[activeId];
29+
if (task) {
30+
for (const agentId of task.agentIds) markDirty(agentId);
31+
for (const shellId of task.shellAgentIds) markDirty(shellId);
32+
}
33+
const terminal = store.terminals[activeId];
34+
if (terminal) markDirty(terminal.agentId);
35+
});
3236
// Cache PanelChild objects by ID so <For> sees stable references
3337
// and doesn't unmount/remount panels when taskOrder changes.
3438
const panelCache = new Map<string, PanelChild>();
@@ -48,7 +52,7 @@ export function TilingLayout() {
4852
cached = {
4953
id: panelId,
5054
initialSize: 520,
51-
minSize: 300,
55+
minSize: 520,
5256
content: () => {
5357
const task = store.tasks[panelId];
5458
const terminal = store.terminals[panelId];
@@ -334,15 +338,52 @@ export function TilingLayout() {
334338
</div>
335339
}
336340
>
337-
<ResizablePanel
338-
direction="horizontal"
339-
children={panelChildren()}
340-
fitContent
341-
persistKey="tiling"
342-
onHandle={(h) => {
343-
panelHandle = h;
341+
<div
342+
style={{
343+
display: 'flex',
344+
'flex-direction': 'row',
345+
width: '100%',
346+
'min-width': '100%',
347+
height: '100%',
348+
position: 'relative',
344349
}}
345-
/>
350+
>
351+
<For each={panelChildren()}>
352+
{(child) => {
353+
const isPlaceholder = child.id === '__placeholder';
354+
const hidden = () =>
355+
store.focusMode && !isPlaceholder && child.id !== store.activeTaskId;
356+
const hidePlaceholder = () => store.focusMode && isPlaceholder;
357+
return (
358+
<div
359+
style={{
360+
// Focus mode: stack all panels on top of each other at full size
361+
// so terminals always compute correct dimensions.
362+
// Normal mode: standard flex row layout.
363+
...(store.focusMode && !isPlaceholder
364+
? {
365+
position: hidden() ? 'absolute' : 'relative',
366+
inset: '0',
367+
width: '100%',
368+
height: '100%',
369+
visibility: hidden() ? 'hidden' : undefined,
370+
'pointer-events': hidden() ? 'none' : undefined,
371+
}
372+
: {
373+
flex: isPlaceholder ? '0 0 54px' : '1 1 0px',
374+
'min-width': isPlaceholder ? undefined : '520px',
375+
height: '100%',
376+
display: hidePlaceholder() ? 'none' : undefined,
377+
}),
378+
overflow: 'hidden',
379+
}}
380+
>
381+
{child.content()}
382+
</div>
383+
);
384+
}}
385+
</For>
386+
</div>
346387
</Show>
347388
</div>
348389
);

src/store/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const [store, setStore] = createStore<AppStore>({
5656
connectedClients: 0,
5757
},
5858
showArena: false,
59+
focusMode: false,
5960
});
6061

6162
export function updateWindowTitle(_taskName?: string): void {

src/store/persistence.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export async function saveState(): Promise<void> {
4343
inactiveColumnOpacity: store.inactiveColumnOpacity,
4444
editorCommand: store.editorCommand || undefined,
4545
customAgents: store.customAgents.length > 0 ? [...store.customAgents] : undefined,
46+
focusMode: store.focusMode || undefined,
4647
};
4748

4849
for (const taskId of store.taskOrder) {
@@ -175,6 +176,7 @@ interface LegacyPersistedState {
175176
editorCommand?: unknown;
176177
customAgents?: unknown;
177178
terminals?: unknown;
179+
focusMode?: unknown;
178180
}
179181

180182
export async function loadState(): Promise<void> {
@@ -280,6 +282,7 @@ export async function loadState(): Promise<void> {
280282

281283
const rawEditorCommand = raw.editorCommand;
282284
s.editorCommand = typeof rawEditorCommand === 'string' ? rawEditorCommand.trim() : '';
285+
s.focusMode = raw.focusMode === true;
283286

284287
// Restore custom agents
285288
if (Array.isArray(raw.customAgents)) {

src/store/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export {
9292
setPanelSizes,
9393
toggleSidebar,
9494
toggleArena,
95+
toggleFocusMode,
9596
setTerminalFont,
9697
setThemePreset,
9798
setAutoTrustFolders,

src/store/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export interface PersistedState {
117117
inactiveColumnOpacity?: number;
118118
editorCommand?: string;
119119
customAgents?: AgentDef[];
120+
focusMode?: boolean;
120121
}
121122

122123
// Panel cell IDs. Shell terminals use "shell:0", "shell:1", etc.
@@ -183,4 +184,5 @@ export interface AppStore {
183184
missingProjectIds: Record<string, true>;
184185
remoteAccess: RemoteAccess;
185186
showArena: boolean;
187+
focusMode: boolean;
186188
}

src/store/ui.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ export function toggleArena(show?: boolean): void {
9999
setStore('showArena', show ?? !store.showArena);
100100
}
101101

102+
export function toggleFocusMode(on?: boolean): void {
103+
setStore('focusMode', on ?? !store.focusMode);
104+
}
105+
102106
export function setWindowState(windowState: PersistedWindowState): void {
103107
const current = store.windowState;
104108
if (

0 commit comments

Comments
 (0)