Skip to content

Commit a068420

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 a068420

File tree

7 files changed

+125
-30
lines changed

7 files changed

+125
-30
lines changed

src/components/TaskPanel.tsx

Lines changed: 51 additions & 8 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">
@@ -643,17 +662,39 @@ export function TaskPanel(props: TaskPanelProps) {
643662
outline: 'none',
644663
}}
645664
onKeyDown={(e) => {
646-
if (e.key === 'Enter') { e.preventDefault(); setPlanFullscreen(true); return; }
665+
if (e.key === 'Enter') {
666+
e.preventDefault();
667+
setPlanFullscreen(true);
668+
return;
669+
}
647670
if (!planScrollRef) return;
648671
const step = 40;
649672
const page = Math.max(100, planScrollRef.clientHeight - 40);
650673
switch (e.key) {
651-
case 'ArrowDown': e.preventDefault(); planScrollRef.scrollTop += step; break;
652-
case 'ArrowUp': e.preventDefault(); planScrollRef.scrollTop -= step; break;
653-
case 'PageDown': e.preventDefault(); planScrollRef.scrollTop += page; break;
654-
case 'PageUp': e.preventDefault(); planScrollRef.scrollTop -= page; break;
655-
case 'Home': e.preventDefault(); planScrollRef.scrollTop = 0; break;
656-
case 'End': e.preventDefault(); planScrollRef.scrollTop = planScrollRef.scrollHeight; break;
674+
case 'ArrowDown':
675+
e.preventDefault();
676+
planScrollRef.scrollTop += step;
677+
break;
678+
case 'ArrowUp':
679+
e.preventDefault();
680+
planScrollRef.scrollTop -= step;
681+
break;
682+
case 'PageDown':
683+
e.preventDefault();
684+
planScrollRef.scrollTop += page;
685+
break;
686+
case 'PageUp':
687+
e.preventDefault();
688+
planScrollRef.scrollTop -= page;
689+
break;
690+
case 'Home':
691+
e.preventDefault();
692+
planScrollRef.scrollTop = 0;
693+
break;
694+
case 'End':
695+
e.preventDefault();
696+
planScrollRef.scrollTop = planScrollRef.scrollHeight;
697+
break;
657698
}
658699
}}
659700
// eslint-disable-next-line solid/no-innerhtml -- plan files are local, written by Claude Code in the worktree
@@ -756,7 +797,9 @@ export function TaskPanel(props: TaskPanelProps) {
756797
ref={shellToolbarRef}
757798
class="focusable-panel shell-toolbar-panel"
758799
tabIndex={0}
759-
onClick={() => setTaskFocusedPanel(props.task.id, `shell-toolbar:${shellToolbarIdx()}`)}
800+
onClick={() =>
801+
setTaskFocusedPanel(props.task.id, `shell-toolbar:${shellToolbarIdx()}`)
802+
}
760803
onFocus={() => setShellToolbarFocused(true)}
761804
onBlur={() => setShellToolbarFocused(false)}
762805
onKeyDown={(e) => {

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)