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
4 changes: 3 additions & 1 deletion electron/ipc/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const processedCount = new Map<string, number>();
function sendStepsContent(win: BrowserWindow, taskId: string, stepsFile: string): void {
if (win.isDestroyed()) return;
const steps = readStepsFile(stepsFile);
console.warn('[steps.send]', taskId, 'len=', steps?.length ?? 'null');
if (steps) applyTimestamps(steps, stepsFile, taskId);
win.webContents.send(IPC.StepsContent, { taskId, steps });
}
Expand Down Expand Up @@ -155,7 +156,8 @@ export function startStepsWatcher(win: BrowserWindow, taskId: string, worktreePa
};

// filename may be null on some platforms; if present, filter to steps.json only
const onChange = (_event: string, filename: string | Buffer | null) => {
const onChange = (event: string, filename: string | Buffer | null) => {
console.warn('[steps.watch]', taskId, event, String(filename));
if (filename !== null && filename !== 'steps.json') return;
const current = watchers.get(taskId);
if (!current) return;
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ function App() {
const offStepsContent = window.electron.ipcRenderer.on(IPC.StepsContent, (data: unknown) => {
if (!data || typeof data !== 'object') return;
const msg = data as { taskId: string; steps: unknown[] | null };
console.warn('[steps.recv]', msg.taskId, 'len=', msg.steps?.length ?? 'null');
if (msg.taskId && store.tasks[msg.taskId]) {
setStepsContent(msg.taskId, msg.steps);
}
Expand Down
19 changes: 19 additions & 0 deletions src/components/TaskTitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
updateTaskName,
collapseTask,
getTaskDotStatus,
toggleFocusMode,
} from '../store/store';
import { EditableText, type EditableTextHandle } from './EditableText';
import { IconButton } from './IconButton';
Expand Down Expand Up @@ -147,6 +148,24 @@ export function TaskTitleBar(props: TaskTitleBarProps) {
</Show>
</div>
</Show>
<IconButton
icon={
store.focusMode ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<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" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<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" />
</svg>
)
}
onClick={() => {
if (!store.focusMode) setActiveTask(props.task.id);
toggleFocusMode();
}}
title={store.focusMode ? 'Exit focus mode' : 'Focus on this task'}
/>
<IconButton
icon={
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
Expand Down
141 changes: 128 additions & 13 deletions src/components/TilingLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import {
Show,
For,
createMemo,
createEffect,
createSignal,
onMount,
onCleanup,
ErrorBoundary,
type JSX,
} from 'solid-js';
import {
store,
pickAndAddProject,
closeTerminal,
setTaskViewportVisibility,
taskNeedsAttention,
getPanelSize,
setPanelSizes,
} from '../store/store';
import { closeTask } from '../store/tasks';
import { ResizablePanel, type PanelChild, type ResizablePanelHandle } from './ResizablePanel';
import type { PanelChild } from './ResizablePanel';
import { TaskPanel } from './TaskPanel';
import { TerminalPanel } from './TerminalPanel';
import { NewTaskPlaceholder } from './NewTaskPlaceholder';
import { markDirty } from '../lib/terminalFitManager';
import { theme } from '../lib/theme';
import { mod } from '../lib/platform';
import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom';
Expand All @@ -27,9 +32,20 @@ const VIEWPORT_EPSILON_PX = 4;

export function TilingLayout() {
let containerRef: HTMLDivElement | undefined;
let panelHandle: ResizablePanelHandle | undefined;
const [hasOverflowLeft, setHasOverflowLeft] = createSignal(false);
const [hasOverflowRight, setHasOverflowRight] = createSignal(false);
const [dragging, setDragging] = createSignal<number | null>(null);
// Transient per-drag width overrides. Written on mousemove, committed to
// store.panelSizes on mouseup. Keeps autosave's snapshot stable mid-drag.
const [dragPreview, setDragPreview] = createSignal<Record<string, number>>({});

function sizeFor(child: PanelChild): number {
const preview = dragPreview()[child.id];
if (preview !== undefined) return preview;
const saved = getPanelSize(`tiling:${child.id}`);
if (saved !== undefined) return saved;
return child.initialSize ?? 200;
}

const syncTaskViewportVisibility = (
entries: Record<string, 'visible' | 'offscreen-left' | 'offscreen-right'>,
Expand All @@ -51,7 +67,7 @@ export function TilingLayout() {
};

const updateViewportState = () => {
if (!containerRef) {
if (!containerRef || store.focusMode) {
setHasOverflowLeft(false);
setHasOverflowRight(false);
syncTaskViewportVisibility({});
Expand Down Expand Up @@ -99,7 +115,16 @@ export function TilingLayout() {
onMount(() => {
if (!containerRef) return;
const handleWheel = createCtrlShiftWheelResizeHandler((deltaPx) => {
panelHandle?.resizeAll(deltaPx);
if (store.focusMode) return;
const entries: Record<string, number> = {};
for (const child of panelChildren()) {
if (child.fixed) continue;
const current = sizeFor(child);
const min = child.minSize ?? 30;
const max = child.maxSize ?? Infinity;
entries[`tiling:${child.id}`] = Math.min(max, Math.max(min, current + deltaPx));
}
setPanelSizes(entries);
requestAnimationFrame(() => updateViewportState());
});
let scrollRafPending = false;
Expand Down Expand Up @@ -143,10 +168,12 @@ export function TilingLayout() {
requestAnimationFrame(() => updateViewportState());
});

// Scroll the active task panel into view when selection changes
// Scroll the active task panel into view when selection changes.
// No-op in focus mode: panels are absolute-positioned, scrolling is meaningless.
createEffect(() => {
const activeId = store.activeTaskId;
if (!containerRef) return;
if (store.focusMode) return;
if (!activeId) {
updateViewportState();
return;
Expand All @@ -156,6 +183,22 @@ export function TilingLayout() {
el?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
requestAnimationFrame(() => updateViewportState());
});

// In focus mode: re-fit terminals of the newly active task so xterm picks up
// the full-width container dimensions (visibility:hidden doesn't trigger
// ResizeObserver).
createEffect(() => {
const activeId = store.activeTaskId;
if (!store.focusMode || !activeId) return;
const task = store.tasks[activeId];
if (task) {
for (const agentId of task.agentIds) markDirty(agentId);
for (const shellId of task.shellAgentIds) markDirty(shellId);
}
const terminal = store.terminals[activeId];
if (terminal) markDirty(terminal.agentId);
});

// Cache PanelChild objects by ID so <For> sees stable references
// and doesn't unmount/remount panels when taskOrder changes.
const panelCache = new Map<string, PanelChild>();
Expand Down Expand Up @@ -295,6 +338,34 @@ export function TilingLayout() {
return panels;
});

function handleDragStart(index: number, e: MouseEvent) {
const panels = panelChildren();
const child = panels[index];
if (!child || child.fixed) return;
e.preventDefault();
const startX = e.clientX;
const startSize = sizeFor(child);
const minSize = child.minSize ?? 30;
const maxSize = child.maxSize ?? Infinity;
const key = `tiling:${child.id}`;
let latest = startSize;
setDragging(index);

function onMove(ev: MouseEvent) {
latest = Math.min(maxSize, Math.max(minSize, startSize + (ev.clientX - startX)));
setDragPreview({ [child.id]: latest });
}
function onUp() {
setDragging(null);
setDragPreview({});
setPanelSizes({ [key]: latest });
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}

return (
<div class="tiling-layout-shell">
<div ref={containerRef} class="tiling-layout-strip">
Expand Down Expand Up @@ -454,15 +525,59 @@ export function TilingLayout() {
</div>
}
>
<ResizablePanel
direction="horizontal"
children={panelChildren()}
fitContent
persistKey="tiling"
onHandle={(h) => {
panelHandle = h;
<div
style={{
display: 'flex',
'flex-direction': 'row',
height: '100%',
position: 'relative',
...(store.focusMode
? { width: '100%', overflow: 'hidden' }
: { width: 'fit-content', 'min-width': '100%' }),
}}
/>
>
<For each={panelChildren()}>
{(child, i) => {
const wrapperStyle = createMemo((): JSX.CSSProperties => {
const isPlaceholder = child.id === '__placeholder';
if (store.focusMode) {
if (isPlaceholder) return { display: 'none' };
const isActive = child.id === store.activeTaskId;
return {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
visibility: isActive ? 'visible' : 'hidden',
'pointer-events': isActive ? 'auto' : 'none',
overflow: 'hidden',
};
}
const s = sizeFor(child);
const min = child.minSize ?? 0;
return {
width: `${s}px`,
'min-width': `${min}px`,
'flex-shrink': '0',
overflow: 'hidden',
};
});
const showHandle = () =>
!store.focusMode && !child.fixed && i() < panelChildren().length - 1;
return (
<>
<div style={wrapperStyle()}>{child.content()}</div>
<Show when={showHandle()}>
<div
class={`resize-handle resize-handle-h ${dragging() === i() ? 'dragging' : ''}`}
onMouseDown={(e) => handleDragStart(i(), e)}
/>
</Show>
</>
);
}}
</For>
</div>
</Show>
</div>

Expand Down
1 change: 1 addition & 0 deletions src/store/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function persistedSnapshot(): string {
inactiveColumnOpacity: store.inactiveColumnOpacity,
editorCommand: store.editorCommand,
customAgents: store.customAgents,
focusMode: store.focusMode,
tasks: Object.fromEntries(
[...store.taskOrder, ...store.collapsedTaskOrder]
.filter((id) => store.tasks[id])
Expand Down
1 change: 1 addition & 0 deletions src/store/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const [store, setStore] = createStore<AppStore>({
connectedClients: 0,
},
showArena: false,
focusMode: false,
});

type CleanupPanelStore = Pick<
Expand Down
16 changes: 16 additions & 0 deletions src/store/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export async function saveState(): Promise<void> {
editorCommand: store.editorCommand || undefined,
dockerImage: store.dockerImage !== 'parallel-code-agent:latest' ? store.dockerImage : undefined,
customAgents: store.customAgents.length > 0 ? [...store.customAgents] : undefined,
focusMode: store.focusMode || undefined,
};

for (const taskId of store.taskOrder) {
Expand Down Expand Up @@ -205,6 +206,7 @@ interface LegacyPersistedState {
dockerImage?: unknown;
customAgents?: unknown;
terminals?: unknown;
focusMode?: unknown;
}

export async function loadState(): Promise<void> {
Expand Down Expand Up @@ -327,6 +329,8 @@ export async function loadState(): Promise<void> {
const rawEditorCommand = raw.editorCommand;
s.editorCommand = typeof rawEditorCommand === 'string' ? rawEditorCommand.trim() : '';

s.focusMode = raw.focusMode === true;

const rawDockerImage = raw.dockerImage;
s.dockerImage =
typeof rawDockerImage === 'string' && rawDockerImage.trim()
Expand Down Expand Up @@ -471,6 +475,18 @@ export async function loadState(): Promise<void> {
const activeSet = new Set(s.taskOrder);
s.collapsedTaskOrder = s.collapsedTaskOrder.filter((id) => !activeSet.has(id));

// Focus mode requires a valid active panel; without one, every panel is
// hidden and the strip reads blank. Repair or drop focus mode.
if (s.focusMode) {
const activeValid =
s.activeTaskId !== null &&
(s.tasks[s.activeTaskId] !== undefined || s.terminals[s.activeTaskId] !== undefined);
if (!activeValid) {
s.activeTaskId = s.taskOrder[0] ?? null;
if (s.activeTaskId === null) s.focusMode = false;
}
}

// Set activeAgentId from the active task
if (s.activeTaskId && s.tasks[s.activeTaskId]) {
s.activeAgentId = s.tasks[s.activeTaskId].agentIds[0] ?? null;
Expand Down
1 change: 1 addition & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export {
setTaskViewportVisibility,
toggleSidebar,
toggleArena,
toggleFocusMode,
setTerminalFont,
setThemePreset,
setAutoTrustFolders,
Expand Down
2 changes: 2 additions & 0 deletions src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export interface PersistedState {
editorCommand?: string;
dockerImage?: string;
customAgents?: AgentDef[];
focusMode?: boolean;
}

// Panel cell IDs. Shell terminals use "shell:0", "shell:1", etc.
Expand Down Expand Up @@ -210,4 +211,5 @@ export interface AppStore {
missingProjectIds: Record<string, true>;
remoteAccess: RemoteAccess;
showArena: boolean;
focusMode: boolean;
}
13 changes: 10 additions & 3 deletions src/store/ui.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { batch } from 'solid-js';
import { store, setStore } from './core';
import type { LookPreset } from '../lib/look';
import type { PersistedWindowState, TaskViewportVisibility } from './types';
Expand Down Expand Up @@ -30,9 +31,11 @@ export function getPanelSize(key: string): number | undefined {
}

export function setPanelSizes(entries: Record<string, number>): void {
for (const [key, value] of Object.entries(entries)) {
setStore('panelSizes', key, value);
}
batch(() => {
for (const [key, value] of Object.entries(entries)) {
setStore('panelSizes', key, value);
}
});
}

export function getTaskViewportVisibility(taskId: string): TaskViewportVisibility | null {
Expand Down Expand Up @@ -97,6 +100,10 @@ export function toggleArena(show?: boolean): void {
setStore('showArena', show ?? !store.showArena);
}

export function toggleFocusMode(on?: boolean): void {
setStore('focusMode', on ?? !store.focusMode);
}

export function setWindowState(windowState: PersistedWindowState): void {
const current = store.windowState;
if (
Expand Down
Loading