diff --git a/src/components/TilingLayout.styles.test.ts b/src/components/TilingLayout.styles.test.ts new file mode 100644 index 00000000..cba62357 --- /dev/null +++ b/src/components/TilingLayout.styles.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const css = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8'); +const tilingLayoutSource = readFileSync( + resolve(process.cwd(), 'src/components/TilingLayout.tsx'), + 'utf8', +); + +function hasRule(selector: string): boolean { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?:^|\\n)${escapedSelector}\\s*\\{([^}]*)\\}`).test(css); +} + +function declarationsFor(selector: string): Record { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(`(?:^|\\n)${escapedSelector}\\s*\\{([^}]*)\\}`)); + if (!match) throw new Error(`Missing CSS rule for ${selector}`); + + return Object.fromEntries( + match[1] + .split(';') + .map((declaration) => declaration.trim()) + .filter(Boolean) + .map((declaration) => { + const separatorIndex = declaration.indexOf(':'); + return [ + declaration.slice(0, separatorIndex).trim(), + declaration.slice(separatorIndex + 1).trim(), + ]; + }), + ); +} + +describe('tiling layout group divider styles', () => { + it('keeps the group-between resize handle inside the dark group gap', () => { + const groupWrapper = declarationsFor('.panel-group-wrapper'); + const groupBetweenHandle = declarationsFor('.group-between-handle'); + const islandsGroupBetweenHandle = declarationsFor( + "html[data-look^='islands-'] .tiling-layout-strip > .group-between-handle", + ); + + expect(groupWrapper['margin-right']).toBe('0'); + expect(groupBetweenHandle.width).toBe('10px'); + expect(groupBetweenHandle.margin).toBe('0'); + expect(islandsGroupBetweenHandle.margin).toBe('0'); + }); + + it('keeps inner group resize handles visible in expanded groups', () => { + expect(hasRule('.resize-handle-h.group-inner-handle::before')).toBe(false); + }); + + it('fills inner group resize handle gaps with the group background', () => { + const groupInnerHandle = declarationsFor('.resize-handle-h.group-inner-handle'); + const groupInnerHandleHover = declarationsFor('.resize-handle-h.group-inner-handle:hover'); + + expect(groupInnerHandle.margin).toBe('0'); + expect(groupInnerHandle.position).toBe('relative'); + expect(groupInnerHandle['z-index']).toBe('3'); + expect(groupInnerHandle.background).toBe('inherit'); + expect(groupInnerHandleHover.background).toContain('color-mix'); + }); +}); + +describe('tiling layout group collapse controls', () => { + it('uses prominent arrow icons for collapse and expand controls', () => { + const collapseIcon = declarationsFor('.panel-group-collapse-btn svg'); + const expandIcon = declarationsFor('.panel-group-expand-btn svg'); + + expect(collapseIcon.width).toBe('16px'); + expect(collapseIcon.height).toBe('16px'); + expect(expandIcon.width).toBe('16px'); + expect(expandIcon.height).toBe('16px'); + }); + + it('keeps the expand control as a side strip beside the visible collapsed panel', () => { + const expandButton = declarationsFor('.panel-group-expand-btn'); + + expect(expandButton.position).toBe('relative'); + expect(expandButton.width).toBe('18px'); + expect(expandButton['flex-shrink']).toBe('0'); + expect(expandButton.background).toBe('transparent'); + }); + + it('uses the same color treatment for collapse and expand controls', () => { + const collapseButton = declarationsFor('.panel-group-collapse-btn'); + const expandButton = declarationsFor('.panel-group-expand-btn'); + const collapseButtonHover = declarationsFor('.panel-group-collapse-btn:hover'); + const expandButtonHover = declarationsFor('.panel-group-expand-btn:hover'); + + expect(expandButton.background).toBe(collapseButton.background); + expect(collapseButton.color).toBe('var(--fg-subtle)'); + expect(expandButton.color).toBe(collapseButton.color); + expect(expandButtonHover.filter).toBe(collapseButtonHover.filter); + expect(expandButtonHover.color).toBe(collapseButtonHover.color); + expect(tilingLayoutSource).not.toContain('style={{ color: info().color }}'); + }); +}); + +describe('task panel active styles', () => { + it('does not let panel group colors tint inactive task panels through opacity', () => { + const taskColumn = declarationsFor('.task-column'); + + expect(taskColumn.opacity).toBeUndefined(); + }); + + it('draws a neutral gray overlay over inactive task panels', () => { + const inactiveOverlay = declarationsFor('.task-column:not(.active)::before'); + + expect(inactiveOverlay.content).toBe("''"); + expect(inactiveOverlay.background).toBe('rgba(128, 128, 128, 0.18)'); + expect(inactiveOverlay['pointer-events']).toBe('none'); + }); + + it('does not draw an accent glow around the active task panel', () => { + const activeTaskColumn = declarationsFor('.task-column.active'); + + expect(activeTaskColumn['box-shadow']).toBeUndefined(); + expect(activeTaskColumn.opacity).toBe('1'); + }); + + it('does not draw an accent outline around a collapsed active panel group', () => { + expect(tilingLayoutSource).not.toContain('groupCollapsed() && groupHasActive()'); + expect(tilingLayoutSource).not.toContain('inset 0 0 0 2px ${theme.accent}'); + }); +}); diff --git a/src/components/TilingLayout.test.ts b/src/components/TilingLayout.test.ts new file mode 100644 index 00000000..87db0fb2 --- /dev/null +++ b/src/components/TilingLayout.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/terminalFitManager', () => ({ + markDirty: vi.fn(), +})); + +import { + buildGroupPanelEntriesForTest, + buildRenderSegmentsForTest, + groupResizeHandleIndexForTest, + groupPanelInnerHandleHiddenForTest, + groupWrapperPaddingForTest, + hiddenPanelContentStyleForTest, + isPanelGroupCollapseControlVisibleForTest, + isPanelGroupCollapsibleForTest, + isGroupPanelHiddenForTest, +} from './TilingLayout'; + +describe('TilingLayout panel group collapse planning', () => { + it('keeps the first task panel visible when a group is collapsed', () => { + const entries = buildGroupPanelEntriesForTest({ + projectId: 'project-1', + groupType: 'independent', + panelIds: ['task-1', 'task-2'], + color: '#334455', + collapsed: true, + }); + + expect(entries.map((entry) => entry.id)).toEqual(['task-1', 'task-2']); + expect(entries.filter((entry) => entry.type === 'panel').map((entry) => entry.hidden)).toEqual([ + false, + true, + ]); + expect(entries.some((entry) => entry.type !== 'panel')).toBe(false); + }); + + it('does not add a collapsed placeholder for expanded groups', () => { + const entries = buildGroupPanelEntriesForTest({ + projectId: 'project-1', + groupType: 'independent', + panelIds: ['task-1', 'task-2'], + color: '#334455', + collapsed: false, + }); + + expect(entries.map((entry) => entry.id)).toEqual(['task-1', 'task-2']); + expect(entries.map((entry) => entry.hidden)).toEqual([false, false]); + }); + + it('derives hidden state from the current collapsed state', () => { + const [, secondEntry] = buildGroupPanelEntriesForTest({ + projectId: 'project-1', + groupType: 'independent', + panelIds: ['task-1', 'task-2'], + color: '#334455', + collapsed: false, + }); + + expect(isGroupPanelHiddenForTest(secondEntry.groupInfo, false)).toBe(false); + expect(isGroupPanelHiddenForTest(secondEntry.groupInfo, true)).toBe(true); + }); + + it('keeps the group background padding when collapsed', () => { + expect(groupWrapperPaddingForTest(false)).toBe('0 6px'); + expect(groupWrapperPaddingForTest(true)).toBe('0 6px'); + }); + + it('anchors the collapsed group resize handle to the visible first panel', () => { + expect(groupResizeHandleIndexForTest(3, 5, false)).toBe(5); + expect(groupResizeHandleIndexForTest(3, 5, true)).toBe(3); + }); + + it('hides handles between task panels while a group is collapsed', () => { + expect(groupPanelInnerHandleHiddenForTest(false, false)).toBe(false); + expect(groupPanelInnerHandleHiddenForTest(true, false)).toBe(true); + expect(groupPanelInnerHandleHiddenForTest(true, true)).toBe(true); + }); + + it('supports deriving inner handle visibility after collapsed state changes', () => { + let collapsed = false; + const hideHandle = () => groupPanelInnerHandleHiddenForTest(collapsed, false); + + expect(hideHandle()).toBe(false); + collapsed = true; + expect(hideHandle()).toBe(true); + }); + + it('does not allow a single task panel group to collapse', () => { + expect(isPanelGroupCollapsibleForTest(1)).toBe(false); + expect(isPanelGroupCollapsibleForTest(2)).toBe(true); + }); + + it('hides the collapse control for a single task panel group', () => { + expect(isPanelGroupCollapseControlVisibleForTest(false, false, true, 1)).toBe(false); + expect(isPanelGroupCollapseControlVisibleForTest(false, false, true, 2)).toBe(true); + }); + + it('changes segment keys when a single slot becomes a grouped project panel', () => { + const before = buildRenderSegmentsForTest([ + { id: 'task-1', groupInfo: undefined }, + { id: '__placeholder', groupInfo: undefined }, + ]); + const after = buildRenderSegmentsForTest([ + { + id: 'task-1', + groupInfo: { + projectId: 'project-1', + groupType: 'independent', + isFirst: true, + isLast: false, + panelCount: 2, + color: '#334455', + }, + }, + { + id: 'task-2', + groupInfo: { + projectId: 'project-1', + groupType: 'independent', + isFirst: false, + isLast: true, + panelCount: 2, + color: '#334455', + }, + }, + { id: '__placeholder', groupInfo: undefined }, + ]); + + expect(before[0]).toMatchObject({ type: 'single', key: 'single:task-1' }); + expect(after[0]).toMatchObject({ type: 'group', key: 'group:project-1:independent' }); + }); + + it('reuses unchanged render segment objects so collapsed groups do not remount panels', () => { + const items = [ + { + id: 'task-1', + groupInfo: { + projectId: 'project-1', + groupType: 'independent' as const, + isFirst: true, + isLast: false, + panelCount: 2, + color: '#334455', + }, + }, + { + id: 'task-2', + groupInfo: { + projectId: 'project-1', + groupType: 'independent' as const, + isFirst: false, + isLast: true, + panelCount: 2, + color: '#334455', + }, + }, + { id: '__placeholder', groupInfo: undefined }, + ]; + + const before = buildRenderSegmentsForTest(items); + const after = buildRenderSegmentsForTest(items); + + expect(after[0]).toBe(before[0]); + expect(after[1]).toBe(before[1]); + }); + + it('preserves hidden panel content width so terminals are not resized to zero', () => { + expect(hiddenPanelContentStyleForTest(true, 520)).toMatchObject({ + width: '520px', + height: '100%', + visibility: 'hidden', + 'pointer-events': 'none', + }); + expect(hiddenPanelContentStyleForTest(false, 520)).toMatchObject({ + width: '100%', + height: '100%', + }); + expect(hiddenPanelContentStyleForTest(false, 520)).not.toHaveProperty('visibility'); + expect(hiddenPanelContentStyleForTest(false, 520)).not.toHaveProperty('pointer-events'); + }); +}); diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index c0a4afd4..795b6ec8 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -19,8 +19,11 @@ import { getPanelUserSize, setPanelUserSize, deletePanelUserSize, + isPanelGroupCollapsed, + togglePanelGroupCollapsed, } from '../store/store'; import { closeTask } from '../store/tasks'; +import type { PanelGroupType } from '../store/store'; import { TaskPanel } from './TaskPanel'; import { TerminalPanel } from './TerminalPanel'; import { NewTaskPlaceholder } from './NewTaskPlaceholder'; @@ -31,6 +34,90 @@ import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom'; const VIEWPORT_EPSILON_PX = 4; +interface PanelGroup { + projectId: string; + groupType: PanelGroupType; + panelIds: string[]; + color: string; +} + +interface GroupInfo { + projectId: string; + groupType: PanelGroupType; + isFirst: boolean; + isLast: boolean; + panelCount: number; + color: string; +} + +type TilingSegment = { type: 'group'; group: PanelGroup } | { type: 'panel'; panelId: string }; + +function computeTilingSegments(taskOrder: string[]): TilingSegment[] { + const segments: TilingSegment[] = []; + let current: PanelGroup | null = null; + + for (const panelId of taskOrder) { + const task = store.tasks[panelId]; + if (!task) { + if (current) { + segments.push({ type: 'group', group: current }); + current = null; + } + segments.push({ type: 'panel', panelId }); + continue; + } + const project = store.projects.find((p) => p.id === task.projectId); + if (!project) { + if (current) { + segments.push({ type: 'group', group: current }); + current = null; + } + segments.push({ type: 'panel', panelId }); + continue; + } + const groupType: PanelGroupType = + task.coordinatorMode || task.coordinatedBy ? 'coordinator' : 'independent'; + if (current && current.projectId === task.projectId && current.groupType === groupType) { + current.panelIds.push(panelId); + } else { + if (current) { + segments.push({ type: 'group', group: current }); + } + current = { + projectId: task.projectId, + groupType, + panelIds: [panelId], + color: project.color, + }; + } + } + if (current) { + segments.push({ type: 'group', group: current }); + } + return segments; +} + +function buildGroupInfoMap(segments: TilingSegment[]): Map { + const map = new Map(); + for (const segment of segments) { + if (segment.type === 'group') { + const group = segment.group; + const bg = `color-mix(in srgb, ${group.color} 50%, transparent)`; + for (let i = 0; i < group.panelIds.length; i++) { + map.set(group.panelIds[i], { + projectId: group.projectId, + groupType: group.groupType, + isFirst: i === 0, + isLast: i === group.panelIds.length - 1, + panelCount: group.panelIds.length, + color: bg, + }); + } + } + } + return map; +} + /** Tiling-layout top-level child. Distinct from `PanelChild` because this * layout owns its own horizontal drag model — fixed placeholders, per-panel * min/max widths, pixel-precise persisted sizes — that doesn't map onto the @@ -41,9 +128,192 @@ interface TileChild { minSize?: number; maxSize?: number; fixed?: boolean; + groupInfo?: GroupInfo; content: () => JSX.Element; } +type GroupPanelEntry = { type: 'panel'; id: string; hidden: boolean; groupInfo: GroupInfo }; + +type RenderSegment = + | { type: 'group'; key: string; start: number; end: number; color: string } + | { type: 'single'; key: string; index: number }; + +type RenderSegmentInput = { + id: string; + groupInfo?: GroupInfo; +}; + +const renderSegmentCache = new Map(); + +function cachedRenderSegment(segment: RenderSegment): RenderSegment { + const identity = + segment.type === 'group' + ? `${segment.type}:${segment.key}:${segment.start}:${segment.end}:${segment.color}` + : `${segment.type}:${segment.key}:${segment.index}`; + const cached = renderSegmentCache.get(identity); + if (cached) return cached; + renderSegmentCache.set(identity, segment); + return segment; +} + +function buildRenderSegments(items: RenderSegmentInput[]): RenderSegment[] { + const segments: RenderSegment[] = []; + let i = 0; + while (i < items.length) { + const item = items[i]; + if (item.groupInfo && !item.id.startsWith('__collapsed')) { + const start = i; + const color = item.groupInfo.color; + const key = `group:${item.groupInfo.projectId}:${item.groupInfo.groupType}`; + let end = i; + while (end + 1 < items.length) { + const next = items[end + 1]; + if ( + next.groupInfo?.projectId === item.groupInfo.projectId && + next.groupInfo?.groupType === item.groupInfo.groupType + ) { + end++; + } else { + break; + } + } + segments.push(cachedRenderSegment({ type: 'group', key, start, end, color })); + i = end + 1; + } else { + segments.push(cachedRenderSegment({ type: 'single', key: `single:${item.id}`, index: i })); + i++; + } + } + return segments; +} + +export function buildRenderSegmentsForTest(items: RenderSegmentInput[]): RenderSegment[] { + return buildRenderSegments(items); +} + +function buildGroupPanelEntries(group: PanelGroup, collapsed: boolean): GroupPanelEntry[] { + const groupInfoMap = buildGroupInfoMap([{ type: 'group', group }]); + return group.panelIds.map((id, index) => ({ + type: 'panel', + id, + hidden: collapsed && index > 0, + groupInfo: groupInfoMap.get(id)!, + })); +} + +export function buildGroupPanelEntriesForTest( + group: PanelGroup & { collapsed: boolean }, +): GroupPanelEntry[] { + return buildGroupPanelEntries(group, group.collapsed); +} + +export function isGroupPanelHiddenForTest( + groupInfo: GroupInfo | undefined, + isCollapsed: boolean, +): boolean { + return isGroupPanelHidden(groupInfo, isCollapsed); +} + +export function isPanelGroupCollapsibleForTest(panelCount: number): boolean { + return isPanelGroupCollapsible(panelCount); +} + +function isPanelGroupCollapsible(panelCount: number): boolean { + return panelCount > 1; +} + +function isGroupPanelHidden(groupInfo: GroupInfo | undefined, isCollapsed: boolean): boolean { + return ( + !!groupInfo && + isPanelGroupCollapsible(groupInfo.panelCount) && + isCollapsed && + !groupInfo.isFirst + ); +} + +export function groupWrapperPaddingForTest(isCollapsed: boolean): string { + return groupWrapperPadding(isCollapsed); +} + +function groupWrapperPadding(_isCollapsed: boolean): string { + return '0 6px'; +} + +export function groupResizeHandleIndexForTest( + groupStartIndex: number, + groupEndIndex: number, + isCollapsed: boolean, +): number { + return groupResizeHandleIndex(groupStartIndex, groupEndIndex, isCollapsed); +} + +function groupResizeHandleIndex( + groupStartIndex: number, + groupEndIndex: number, + isCollapsed: boolean, +): number { + return isCollapsed ? groupStartIndex : groupEndIndex; +} + +export function groupPanelInnerHandleHiddenForTest( + isCollapsed: boolean, + isLastInGroup: boolean, +): boolean { + return groupPanelInnerHandleHidden(isCollapsed, isLastInGroup); +} + +function groupPanelInnerHandleHidden(isCollapsed: boolean, isLastInGroup: boolean): boolean { + return isCollapsed || isLastInGroup; +} + +export function isPanelGroupCollapseControlVisibleForTest( + childHidden: boolean, + focusMode: boolean, + isLastInGroup: boolean | undefined, + panelCount: number | undefined, +): boolean { + return isPanelGroupCollapseControlVisible(childHidden, focusMode, isLastInGroup, panelCount); +} + +function isPanelGroupCollapseControlVisible( + childHidden: boolean, + focusMode: boolean, + isLastInGroup: boolean | undefined, + panelCount: number | undefined, +): boolean { + return ( + !childHidden && + !focusMode && + isLastInGroup === true && + panelCount !== undefined && + isPanelGroupCollapsible(panelCount) + ); +} + +function hiddenPanelContentStyle( + hidden: boolean, + contentWidth: number | undefined, +): JSX.CSSProperties { + return hidden + ? { + width: `${contentWidth ?? 0}px`, + height: '100%', + visibility: 'hidden', + 'pointer-events': 'none', + } + : { + width: '100%', + height: '100%', + }; +} + +export function hiddenPanelContentStyleForTest( + hidden: boolean, + contentWidth: number | undefined, +): JSX.CSSProperties { + return hiddenPanelContentStyle(hidden, contentWidth); +} + export function TilingLayout() { let containerRef: HTMLDivElement | undefined; const [hasOverflowLeft, setHasOverflowLeft] = createSignal(false); @@ -219,133 +489,166 @@ export function TilingLayout() { // and doesn't unmount/remount panels when taskOrder changes. const panelCache = new Map(); + function createPanelTileChild(panelId: string): TileChild { + return { + id: panelId, + initialSize: 520, + minSize: 300, + content: () => { + const task = store.tasks[panelId]; + const terminal = store.terminals[panelId]; + // eslint-disable-next-line solid/components-return-once + if (!task && !terminal) return
; + return ( +
{ + if (e.animationName === 'taskAppear') + e.currentTarget.classList.remove('task-appearing'); + }} + > + ( +
+
Panel crashed
+
+ {String(err)} +
+
+ + +
+
+ )} + > + {task ? ( + + ) : terminal ? ( + + ) : null} +
+
+ ); + }, + }; + } + const panelChildren = createMemo((): TileChild[] => { + // Establish reactivity for projects and collapsed state + void store.projects.length; + void Object.keys(store.panelGroupCollapsed).join(','); + const currentIds = new Set(store.taskOrder); currentIds.add('__placeholder'); - // Remove stale entries for deleted tasks + // Remove stale entries for deleted tasks (keep collapsed placeholders) for (const key of panelCache.keys()) { + if (key === '__placeholder') continue; + if (key.startsWith('__collapsed:')) continue; if (!currentIds.has(key)) panelCache.delete(key); } - const panels: TileChild[] = store.taskOrder.map((panelId) => { - let cached = panelCache.get(panelId); - if (!cached) { - cached = { - id: panelId, - initialSize: 520, - minSize: 300, - content: () => { - const task = store.tasks[panelId]; - const terminal = store.terminals[panelId]; - // eslint-disable-next-line solid/components-return-once - if (!task && !terminal) return
; - return ( -
{ - if (e.animationName === 'taskAppear') - e.currentTarget.classList.remove('task-appearing'); - }} - > - ( -
-
Panel crashed
-
- {String(err)} -
-
- - -
-
- )} - > - {task ? ( - - ) : terminal ? ( - - ) : null} -
-
- ); - }, - }; - panelCache.set(panelId, cached); + const segments = computeTilingSegments(store.taskOrder); + const panels: TileChild[] = []; + + for (const segment of segments) { + if (segment.type === 'group') { + const group = segment.group; + const entries = buildGroupPanelEntries( + group, + isPanelGroupCollapsed(group.projectId, group.groupType), + ); + for (const entry of entries) { + const panelId = entry.id; + let cached = panelCache.get(panelId); + if (!cached) { + cached = createPanelTileChild(panelId); + panelCache.set(panelId, cached); + } + cached.groupInfo = entry.groupInfo; + panels.push(cached); + } + } else { + const panelId = segment.panelId; + let cached = panelCache.get(panelId); + if (!cached) { + cached = createPanelTileChild(panelId); + panelCache.set(panelId, cached); + } + cached.groupInfo = undefined; + panels.push(cached); } - return cached; - }); + } let placeholder = panelCache.get('__placeholder'); if (!placeholder) { @@ -390,6 +693,129 @@ export function TilingLayout() { window.addEventListener('mouseup', onUp); } + const renderSegments = createMemo(() => buildRenderSegments(panelChildren())); + + function renderHandle(globalIdx: number, extraClass?: string): JSX.Element { + const panels = panelChildren(); + const child = panels[globalIdx]; + const isGroupInnerHandle = () => { + if (globalIdx >= panels.length - 1) return false; + const next = panels[globalIdx + 1]; + return ( + child?.groupInfo != null && + next?.groupInfo != null && + child.groupInfo.projectId === next.groupInfo.projectId && + child.groupInfo.groupType === next.groupInfo.groupType + ); + }; + return ( +
handleDragStart(globalIdx, e)} + onDblClick={() => { + if (dragging() !== null) return; + const left = panels[globalIdx]; + const right = panels[globalIdx + 1]; + if (!left || !right) return; + deletePanelUserSize([`tiling:${left.id}`, `tiling:${right.id}`]); + requestAnimationFrame(() => updateViewportState()); + }} + /> + ); + } + + function panelItemJSX( + child: TileChild, + globalIdx: number, + total: number, + options?: { hideHandle?: () => boolean }, + ): JSX.Element { + const isPlaceholder = child.id === '__placeholder'; + const childHidden = () => { + const groupInfo = child.groupInfo; + return isGroupPanelHidden( + groupInfo, + groupInfo ? isPanelGroupCollapsed(groupInfo.projectId, groupInfo.groupType) : false, + ); + }; + const childSize = () => sizeFor(child); + + const wrapperStyle = (): JSX.CSSProperties => { + if (childHidden()) { + return { + width: '0', + 'min-width': '0', + height: '100%', + overflow: 'hidden', + visibility: 'hidden', + 'pointer-events': 'none', + 'flex-shrink': '0', + position: 'relative', + }; + } + if (store.focusMode) { + if (isPlaceholder) return { display: 'none' }; + const isActive = child.id === store.activeTaskId; + return { + position: 'absolute', + inset: store.themePreset.startsWith('islands-') ? '0 4px 0 0' : '0', + width: '100%', + height: '100%', + visibility: isActive ? 'visible' : 'hidden', + 'pointer-events': isActive ? 'auto' : 'none', + overflow: 'hidden', + }; + } + const s = childSize(); + const min = child.minSize ?? 0; + return { + width: `${s}px`, + 'min-width': `${min}px`, + 'flex-shrink': '0', + overflow: 'hidden', + position: 'relative', + }; + }; + + const showHandle = () => + !childHidden() && + !options?.hideHandle?.() && + !store.focusMode && + !child.fixed && + globalIdx < total - 1; + const showCollapseBtn = () => + isPanelGroupCollapseControlVisible( + childHidden(), + store.focusMode, + child.groupInfo?.isLast, + child.groupInfo?.panelCount, + ); + + return ( + <> +
+
{child.content()}
+
+ + + + {renderHandle(globalIdx)} + + ); + } + return (
@@ -560,53 +986,74 @@ export function TilingLayout() { : { width: 'fit-content', 'min-width': '100%' }), }} > - - {(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: store.themePreset.startsWith('islands-') ? '0 4px 0 0' : '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 ( - <> -
{child.content()}
- + + {(currentSegment) => { + const total = panelChildren().length; + if (currentSegment.type === 'group') { + const groupPanelCount = currentSegment.end - currentSegment.start + 1; + const firstChild = panelChildren()[currentSegment.start]; + const groupInfo = firstChild?.groupInfo; + const groupCollapsed = () => + groupInfo && isPanelGroupCollapsible(groupInfo.panelCount) + ? isPanelGroupCollapsed(groupInfo.projectId, groupInfo.groupType) + : false; + return ( + <>
handleDragStart(i(), e)} - onDblClick={() => { - if (dragging() !== null) return; - const panels = panelChildren(); - const left = panels[i()]; - const right = panels[i() + 1]; - if (!left || !right) return; - deletePanelUserSize([`tiling:${left.id}`, `tiling:${right.id}`]); - requestAnimationFrame(() => updateViewportState()); + class="panel-group-wrapper" + style={{ + display: 'flex', + 'flex-direction': 'row', + background: currentSegment.color, + 'border-radius': '12px', + overflow: 'hidden', + padding: groupWrapperPadding(groupCollapsed()), }} - /> - - + > + + {(child, localIdx) => { + const isLastInGroup = localIdx() === groupPanelCount - 1; + return panelItemJSX(child, currentSegment.start + localIdx(), total, { + hideHandle: () => + groupPanelInnerHandleHidden(groupCollapsed(), isLastInGroup), + }); + }} + + + {(info) => ( + + )} + +
+ + {renderHandle( + groupResizeHandleIndex( + currentSegment.start, + currentSegment.end, + groupCollapsed(), + ), + 'group-between-handle', + )} + + + ); + } + return panelItemJSX( + panelChildren()[currentSegment.index], + currentSegment.index, + total, ); }}
diff --git a/src/lib/terminalFitManager.test.ts b/src/lib/terminalFitManager.test.ts new file mode 100644 index 00000000..8904c350 --- /dev/null +++ b/src/lib/terminalFitManager.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type ResizeObserverCallback = (entries: Array<{ target: unknown }>) => void; + +let resizeCallback: ResizeObserverCallback | undefined; + +class FakeResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe = vi.fn(); + unobserve = vi.fn(); +} + +class FakeIntersectionObserver { + constructor(_callback: unknown) {} + observe = vi.fn(); + unobserve = vi.fn(); +} + +function makeContainer(width: number, height: number): HTMLElement { + const container = { + offsetWidth: width, + offsetHeight: height, + contains: (node: unknown) => node === container, + } as HTMLElement; + return container; +} + +describe('terminalFitManager', () => { + beforeEach(() => { + vi.resetModules(); + resizeCallback = undefined; + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + vi.stubGlobal('performance', { now: () => 10_000 }); + vi.stubGlobal('window', { + setTimeout: (cb: () => void) => { + cb(); + return 0; + }, + clearTimeout: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('does not fit a terminal while its container is collapsed to zero size', async () => { + const { registerTerminal } = await import('./terminalFitManager'); + const container = makeContainer(0, 0); + const fitAddon = { fit: vi.fn() }; + + registerTerminal('agent-1', container, fitAddon as never, { + buffer: { active: { viewportY: 0, baseY: 0 } }, + scrollToLine: vi.fn(), + } as never); + + resizeCallback?.([{ target: container }]); + + expect(fitAddon.fit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/terminalFitManager.ts b/src/lib/terminalFitManager.ts index e3327da8..ca1f94fe 100644 --- a/src/lib/terminalFitManager.ts +++ b/src/lib/terminalFitManager.ts @@ -42,6 +42,7 @@ function flush() { for (const [, entry] of entries) { if (!entry.dirty) continue; entry.dirty = false; + if (entry.container.offsetWidth <= 0 || entry.container.offsetHeight <= 0) continue; // xterm.js scroll position workaround (xtermjs/xterm.js#5096): // fit() → resize() → Viewport._sync() can reset scrollTop to 0 when diff --git a/src/store/core.ts b/src/store/core.ts index e7ae3749..1acd2b1f 100644 --- a/src/store/core.ts +++ b/src/store/core.ts @@ -86,6 +86,7 @@ export const [store, setStore] = createStore({ customThemes: {}, activeCustomThemeId: null, mcpStatus: { running: false, port: null, coordinatorTaskId: null, mcpConfigPath: null }, + panelGroupCollapsed: {}, }); type CleanupPanelStore = Pick< diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 7a8727cd..aa17b3f2 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -216,6 +216,8 @@ export async function saveState(): Promise { defaultStepsEnabled: store.defaultStepsEnabled || undefined, defaultSkipPermissions: store.defaultSkipPermissions || undefined, defaultPropagateSkipPermissions: store.defaultPropagateSkipPermissions || undefined, + panelGroupCollapsed: + Object.keys(store.panelGroupCollapsed).length > 0 ? { ...store.panelGroupCollapsed } : undefined, }; for (const taskId of store.taskOrder) { @@ -383,6 +385,7 @@ interface LegacyPersistedState { defaultStepsEnabled?: unknown; defaultSkipPermissions?: unknown; defaultPropagateSkipPermissions?: unknown; + panelGroupCollapsed?: unknown; } export async function loadState(): Promise { @@ -586,6 +589,21 @@ export async function loadState(): Promise { s.defaultSkipPermissions = raw.defaultSkipPermissions === true; s.defaultPropagateSkipPermissions = raw.defaultPropagateSkipPermissions === true; + // Restore panel group collapsed state + const restoredGroupCollapsed: Record = {}; + if (raw.panelGroupCollapsed && typeof raw.panelGroupCollapsed === 'object') { + const projectIds = new Set(s.projects.map((p) => p.id)); + for (const [key, value] of Object.entries(raw.panelGroupCollapsed as Record)) { + if (typeof value === 'boolean' && value) { + const projectId = key.split(':')[0]; + if (projectId && projectIds.has(projectId)) { + restoredGroupCollapsed[key] = true; + } + } + } + } + s.panelGroupCollapsed = restoredGroupCollapsed; + const rawDockerImage = raw.dockerImage; s.dockerImage = typeof rawDockerImage === 'string' && rawDockerImage.trim() diff --git a/src/store/store.ts b/src/store/store.ts index 9225d5a1..5d353c48 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -144,6 +144,10 @@ export { setDefaultStepsEnabled, setDefaultSkipPermissions, setDefaultPropagateSkipPermissions, + setPanelGroupCollapsed, + isPanelGroupCollapsed, + togglePanelGroupCollapsed, + type PanelGroupType, } from './ui'; export { getTaskDotStatus, diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index 9754d234..f50e1bab 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -26,6 +26,7 @@ let mockAgents: Record = {}; let mockTaskOrder: string[] = []; let mockCollapsedTaskOrder: string[] = []; let mockProjects: { id: string; path: string }[] = []; +let mockPanelGroupCollapsed: Record = {}; const ipcHandlers = new Map void>(); function applySetStore(...args: unknown[]): void { @@ -36,12 +37,14 @@ function applySetStore(...args: unknown[]): void { agents: Record; taskOrder: string[]; collapsedTaskOrder: string[]; + panelGroupCollapsed: Record; }) => void )({ tasks: mockTasks, agents: mockAgents, taskOrder: mockTaskOrder, collapsedTaskOrder: mockCollapsedTaskOrder, + panelGroupCollapsed: mockPanelGroupCollapsed, }); return; } @@ -77,6 +80,7 @@ vi.mock('./core', () => ({ if (prop === 'agents') return mockAgents; if (prop === 'taskOrder') return mockTaskOrder; if (prop === 'collapsedTaskOrder') return mockCollapsedTaskOrder; + if (prop === 'panelGroupCollapsed') return mockPanelGroupCollapsed; if (prop === 'availableAgents') return []; if (prop === 'projects') return mockProjects; if (prop === 'defaultStepsEnabled') return mockDefaultStepsEnabled; @@ -173,6 +177,7 @@ beforeEach(() => { mockTaskOrder = []; mockCollapsedTaskOrder = []; mockProjects = []; + mockPanelGroupCollapsed = {}; mockDefaultStepsEnabled = false; mockInvoke.mockResolvedValue(undefined); mockSaveState.mockResolvedValue(undefined); @@ -821,6 +826,7 @@ describe('createTask does not mutate defaultStepsEnabled', () => { mockTasks = {}; mockAgents = {}; mockTaskOrder = []; + mockPanelGroupCollapsed = {}; mockDefaultStepsEnabled = false; vi.mocked(getProjectPath).mockReturnValue('/repo'); vi.mocked(getProjectBranchPrefix).mockReturnValue('task'); @@ -852,6 +858,22 @@ describe('createTask does not mutate defaultStepsEnabled', () => { ); expect(defaultsMutated).toBe(false); }); + + it('expands a collapsed project group so a newly active task panel is visible', async () => { + mockPanelGroupCollapsed['proj-1:independent'] = true; + + await createTask({ + name: 'My Task', + agentDef, + projectId: 'proj-1', + gitIsolation: 'worktree', + baseBranch: 'main', + }); + + expect(mockTaskOrder).toEqual(['task-1']); + expect(mockTasks['task-1']).toMatchObject({ projectId: 'proj-1' }); + expect(mockPanelGroupCollapsed['proj-1:independent']).toBe(false); + }); }); // ─── sendPrompt tests ───────────────────────────────────────────────────────── diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 9688c5f2..1536bb36 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -37,6 +37,11 @@ import { import { getCoordinatorChildren, isCoordinatedChild } from './sidebar-order'; import { isLandedTaskState } from './landing'; +function panelGroupKeyForTask(task: Pick) { + const groupType = task.coordinatorMode || task.coordinatedBy ? 'coordinator' : 'independent'; + return `${task.projectId}:${groupType}`; +} + function initTaskInStore( taskId: string, task: Task, @@ -53,6 +58,7 @@ function initTaskInStore( s.activeAgentId = agent.id; s.lastProjectId = projectId; if (agentDef) s.lastAgentId = agentDef.id; + s.panelGroupCollapsed[panelGroupKeyForTask(task)] = false; }), ); markAgentSpawned(agent.id); @@ -987,6 +993,7 @@ export function uncollapseTask(taskId: string): void { t.savedSelectedAgentIndex = undefined; t.savedPromptedAgentIndexes = undefined; s.activeAgentId = t.selectedAgentId ?? null; + s.panelGroupCollapsed[panelGroupKeyForTask(t)] = false; }), ); @@ -1115,6 +1122,7 @@ export function initMCPListeners(): () => void { s.tasks[evt.taskId] = task; s.agents[evt.agentId] = agent; s.taskOrder.push(evt.taskId); + s.panelGroupCollapsed[panelGroupKeyForTask(task)] = false; created = true; }), ); diff --git a/src/store/types.ts b/src/store/types.ts index e2099000..e4b62c39 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -239,6 +239,8 @@ export interface PersistedState { * values. Absent = pre-v2 data, wipe task:* entries on load. */ panelUserSizeMigratedV2?: boolean; globalScale?: number; + /** Collapsed panel groups keyed by `${projectId}:${groupType}`. */ + panelGroupCollapsed?: Record; completedTaskDate?: string; completedTaskCount?: number; mergedLinesAdded?: number; @@ -383,4 +385,7 @@ export interface AppStore { defaultSkipPermissions: boolean; defaultPropagateSkipPermissions: boolean; mcpStatus: MCPStatus; + /** Collapsed panel groups keyed by `${projectId}:${groupType}`. + * `groupType` is 'independent' or 'coordinator'. */ + panelGroupCollapsed: Record; } diff --git a/src/store/ui.ts b/src/store/ui.ts index 09abb112..8a5b69f9 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -284,3 +284,29 @@ export function setWindowState(windowState: PersistedWindowState): void { } setStore('windowState', windowState); } + +// --- Panel Group Collapse --- + +export type PanelGroupType = 'independent' | 'coordinator'; + +function groupKey(projectId: string, groupType: PanelGroupType): string { + return `${projectId}:${groupType}`; +} + +export function setPanelGroupCollapsed( + projectId: string, + groupType: PanelGroupType, + collapsed: boolean, +): void { + const key = groupKey(projectId, groupType); + setStore('panelGroupCollapsed', key, collapsed); +} + +export function isPanelGroupCollapsed(projectId: string, groupType: PanelGroupType): boolean { + return store.panelGroupCollapsed[groupKey(projectId, groupType)] ?? false; +} + +export function togglePanelGroupCollapsed(projectId: string, groupType: PanelGroupType): void { + const key = groupKey(projectId, groupType); + setStore('panelGroupCollapsed', key, !store.panelGroupCollapsed[key]); +} diff --git a/src/styles.css b/src/styles.css index 8757deb5..7e389da7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -499,6 +499,10 @@ html[data-look^='islands-'] .tiling-layout-strip > .resize-handle-h { z-index: 3; } +html[data-look^='islands-'] .tiling-layout-strip > .group-between-handle { + margin: 0; +} + html[data-look^='islands-'] .tiling-layout-strip > .resize-handle-h::before { display: none; } @@ -1310,11 +1314,18 @@ textarea::placeholder { box-shadow: var(--shadow-soft); border-color: color-mix(in srgb, var(--border) 85%, transparent) !important; margin: 0 2px; - opacity: var(--inactive-column-opacity, 0.6); +} + +.task-column:not(.active)::before { + content: ''; + position: absolute; + inset: 0; + background: rgba(128, 128, 128, 0.18); + pointer-events: none; + z-index: 20; } .task-column.active { - box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 50%, transparent); border-color: color-mix(in srgb, var(--border) 85%, transparent) !important; opacity: 1; } @@ -1492,6 +1503,21 @@ textarea::placeholder { cursor: row-resize; } +.resize-handle-h.group-inner-handle { + margin: 0; + position: relative; + z-index: 3; + background: inherit; +} + +.resize-handle-h.group-inner-handle:hover { + background: color-mix(in srgb, var(--accent) 70%, transparent); +} + +.resize-handle-h.group-inner-handle.dragging { + background: color-mix(in srgb, var(--accent) 70%, transparent); +} + .new-task-placeholder { transition: border-color 0.15s ease, @@ -2265,3 +2291,89 @@ body.dragging-task * { opacity: 0.3; } } + +.panel-group-wrapper { + /* Group background wrapper — sits behind all panels in the group. + Background and border-radius are set inline in TilingLayout. */ + position: relative; + flex-shrink: 0; + margin-right: 0; +} + +/* Group-between handle: occupies the dark gap between a grouped set of panels + and the next task panel, so the hover fill never paints over the panel. */ +.group-between-handle { + width: 10px; + margin: 0; + position: relative; + z-index: 3; +} + +html[data-look^='islands-'] .group-between-handle::before { + display: none; +} + +/* Panel group collapse / expand buttons — vertical bar beside the panel */ +.panel-group-collapse-btn { + flex-shrink: 0; + width: 18px; + height: 100%; + padding: 0; + border: none; + background: transparent; + color: var(--fg-subtle); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + filter 0.15s ease, + color 0.15s ease; +} + +.panel-group-collapse-btn:hover { + filter: brightness(1.2); + color: var(--fg); +} + +.panel-group-collapse-btn svg { + width: 16px; + height: 16px; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.35)); + opacity: 0.95; +} + +.panel-group-expand-btn svg { + width: 16px; + height: 16px; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.35)); + opacity: 0.95; +} + +.panel-group-collapse-btn:active { + filter: brightness(0.9); +} + +/* Expand button beside the visible panel in a collapsed group. */ +.panel-group-expand-btn { + position: relative; + flex-shrink: 0; + width: 18px; + height: 100%; + padding: 0; + border: none; + background: transparent; + color: var(--fg-subtle); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + filter 0.15s ease, + color 0.15s ease; +} + +.panel-group-expand-btn:hover { + filter: brightness(1.2); + color: var(--fg); +}