From 9e246204c1e933a9359b64cb56ba6d1bce42e33f Mon Sep 17 00:00:00 2001 From: FourWindff Date: Thu, 11 Jun 2026 21:28:22 +0800 Subject: [PATCH 1/8] feat(panel-groups): implement panel group collapse functionality - Added support for collapsing panel groups, allowing users to manage their workspace more effectively. - Introduced new state management for collapsed panel groups in the store. - Updated UI components to reflect the collapsed state and added expand/collapse buttons. - Enhanced persistence logic to save and restore the collapsed state across sessions. This feature improves user experience by providing better organization of panels. --- src/components/TilingLayout.styles.test.ts | 52 ++ src/components/TilingLayout.test.ts | 39 ++ src/components/TilingLayout.tsx | 641 +++++++++++++++------ src/store/core.ts | 1 + src/store/persistence.ts | 18 + src/store/store.ts | 4 + src/store/types.ts | 5 + src/store/ui.ts | 26 + src/styles.css | 96 +++ 9 files changed, 718 insertions(+), 164 deletions(-) create mode 100644 src/components/TilingLayout.styles.test.ts create mode 100644 src/components/TilingLayout.test.ts diff --git a/src/components/TilingLayout.styles.test.ts b/src/components/TilingLayout.styles.test.ts new file mode 100644 index 000000000..146c4363b --- /dev/null +++ b/src/components/TilingLayout.styles.test.ts @@ -0,0 +1,52 @@ +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'); + +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'); + }); +}); + +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'); + }); +}); diff --git a/src/components/TilingLayout.test.ts b/src/components/TilingLayout.test.ts new file mode 100644 index 000000000..c9c37d9b6 --- /dev/null +++ b/src/components/TilingLayout.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/terminalFitManager', () => ({ + markDirty: vi.fn(), +})); + +import { buildGroupPanelEntriesForTest } from './TilingLayout'; + +describe('TilingLayout panel group collapse planning', () => { + it('keeps task panels mounted 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([ + true, + 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]); + }); +}); diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index c0a4afd4a..938851eef 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -2,6 +2,7 @@ import { batch, Show, For, + Index, createMemo, createEffect, createSignal, @@ -19,8 +20,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 +35,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; + 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, + 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 +129,29 @@ interface TileChild { minSize?: number; maxSize?: number; fixed?: boolean; + hidden?: boolean; + groupInfo?: GroupInfo; content: () => JSX.Element; } +type GroupPanelEntry = { type: 'panel'; id: string; hidden: boolean; groupInfo: GroupInfo }; + +function buildGroupPanelEntries(group: PanelGroup, collapsed: boolean): GroupPanelEntry[] { + const groupInfoMap = buildGroupInfoMap([{ type: 'group', group }]); + return group.panelIds.map((id) => ({ + type: 'panel', + id, + hidden: collapsed, + groupInfo: groupInfoMap.get(id)!, + })); +} + +export function buildGroupPanelEntriesForTest( + group: PanelGroup & { collapsed: boolean }, +): GroupPanelEntry[] { + return buildGroupPanelEntries(group, group.collapsed); +} + export function TilingLayout() { let containerRef: HTMLDivElement | undefined; const [hasOverflowLeft, setHasOverflowLeft] = createSignal(false); @@ -219,133 +327,168 @@ 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; + cached.hidden = entry.hidden; + 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; + cached.hidden = false; + panels.push(cached); } - return cached; - }); + } let placeholder = panelCache.get('__placeholder'); if (!placeholder) { @@ -390,6 +533,146 @@ export function TilingLayout() { window.addEventListener('mouseup', onUp); } + type RenderSegment = + | { type: 'group'; start: number; end: number; color: string } + | { type: 'single'; index: number }; + + const renderSegments = createMemo(() => { + const items = panelChildren(); + 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; + 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({ type: 'group', start, end, color }); + i = end + 1; + } else { + segments.push({ type: 'single', index: i }); + i++; + } + } + return segments; + }); + + 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 wrapperStyle = (): JSX.CSSProperties => { + if (child.hidden) { + 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 = sizeFor(child); + const min = child.minSize ?? 0; + return { + width: `${s}px`, + 'min-width': `${min}px`, + 'flex-shrink': '0', + overflow: 'hidden', + position: 'relative', + }; + }; + + const showHandle = () => + !child.hidden && + !options?.hideHandle && + !store.focusMode && + !child.fixed && + globalIdx < total - 1; + const showCollapseBtn = () => !child.hidden && !store.focusMode && child.groupInfo?.isLast; + + return ( + <> +
{child.content()}
+ + + + {renderHandle(globalIdx)} + + ); + } + return (
@@ -560,56 +843,86 @@ 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()}
- + + {(segment) => { + const total = panelChildren().length; + const currentSegment = segment(); + if (currentSegment.type === 'group') { + const groupPanelCount = currentSegment.end - currentSegment.start + 1; + const firstChild = panelChildren()[currentSegment.start]; + const groupInfo = firstChild?.groupInfo; + const groupCollapsed = () => + groupInfo + ? isPanelGroupCollapsed(groupInfo.projectId, groupInfo.groupType) + : false; + const groupHasActive = () => + panelChildren() + .slice(currentSegment.start, currentSegment.end + 1) + .some((child) => child.id === store.activeTaskId); + 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: groupCollapsed() ? '0' : '0 6px', + width: groupCollapsed() ? '32px' : undefined, + 'box-shadow': + groupCollapsed() && groupHasActive() + ? `inset 0 0 0 2px ${theme.accent}` + : undefined, }} - /> - - + > + + {(child, localIdx) => { + const isLastInGroup = localIdx() === groupPanelCount - 1; + return panelItemJSX( + child, + currentSegment.start + localIdx(), + total, + { hideHandle: isLastInGroup }, + ); + }} + + + {(info) => ( + + )} + +
+ + {renderHandle(currentSegment.end, 'group-between-handle')} + + + ); + } + return panelItemJSX( + panelChildren()[currentSegment.index], + currentSegment.index, + total, ); }} -
+
diff --git a/src/store/core.ts b/src/store/core.ts index e7ae37499..1acd2b1fa 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 7a8727cd7..aa17b3f2d 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 9225d5a11..5d353c48d 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/types.ts b/src/store/types.ts index e20990004..e4b62c39c 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 09abb1129..8a5b69f9a 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 8757deb59..02203e374 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; } @@ -1492,6 +1496,12 @@ textarea::placeholder { cursor: row-resize; } +/* Hide the center hairline on resize handles that sit inside a group + so the group background reads as one continuous surface. */ +.resize-handle-h.group-inner-handle::before { + display: none !important; +} + .new-task-placeholder { transition: border-color 0.15s ease, @@ -2265,3 +2275,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 inside collapsed-placeholder — fills the strip */ +.panel-group-expand-btn { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: none; + background: transparent; + color: inherit; + 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.3); + color: var(--fg); +} From bc0ed72b9ce88d48c8a787d95583804be3058657 Mon Sep 17 00:00:00 2001 From: FourWindff Date: Thu, 11 Jun 2026 22:50:45 +0800 Subject: [PATCH 2/8] feat(styles): enhance resize handle and expand button styles for better UI consistency - Updated styles for inner group resize handles to ensure visibility and proper background blending during hover and dragging states. - Modified the expand button styling to position it beside the visible panel in collapsed groups, improving layout and usability. - Added tests to verify the new styles and their behavior in different states. --- src/components/TilingLayout.styles.test.ts | 29 ++++++++ src/components/TilingLayout.test.ts | 51 ++++++++++++- src/components/TilingLayout.tsx | 87 ++++++++++++++++++---- src/styles.css | 27 ++++--- 4 files changed, 168 insertions(+), 26 deletions(-) diff --git a/src/components/TilingLayout.styles.test.ts b/src/components/TilingLayout.styles.test.ts index 146c4363b..7da439572 100644 --- a/src/components/TilingLayout.styles.test.ts +++ b/src/components/TilingLayout.styles.test.ts @@ -4,6 +4,11 @@ import { describe, expect, it } from 'vitest'; const css = readFileSync(resolve(process.cwd(), 'src/styles.css'), '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*\\{([^}]*)\\}`)); @@ -37,6 +42,21 @@ describe('tiling layout group divider styles', () => { 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', () => { @@ -49,4 +69,13 @@ describe('tiling layout group collapse controls', () => { 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('inherit'); + }); }); diff --git a/src/components/TilingLayout.test.ts b/src/components/TilingLayout.test.ts index c9c37d9b6..57cc66ef3 100644 --- a/src/components/TilingLayout.test.ts +++ b/src/components/TilingLayout.test.ts @@ -4,10 +4,16 @@ vi.mock('../lib/terminalFitManager', () => ({ markDirty: vi.fn(), })); -import { buildGroupPanelEntriesForTest } from './TilingLayout'; +import { + buildGroupPanelEntriesForTest, + groupResizeHandleIndexForTest, + groupPanelInnerHandleHiddenForTest, + groupWrapperPaddingForTest, + isGroupPanelHiddenForTest, +} from './TilingLayout'; describe('TilingLayout panel group collapse planning', () => { - it('keeps task panels mounted when a group is collapsed', () => { + it('keeps the first task panel visible when a group is collapsed', () => { const entries = buildGroupPanelEntriesForTest({ projectId: 'project-1', groupType: 'independent', @@ -18,7 +24,7 @@ describe('TilingLayout panel group collapse planning', () => { expect(entries.map((entry) => entry.id)).toEqual(['task-1', 'task-2']); expect(entries.filter((entry) => entry.type === 'panel').map((entry) => entry.hidden)).toEqual([ - true, + false, true, ]); expect(entries.some((entry) => entry.type !== 'panel')).toBe(false); @@ -36,4 +42,43 @@ describe('TilingLayout panel group collapse planning', () => { 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); + }); + }); diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index 938851eef..f5fa921d1 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -129,7 +129,6 @@ interface TileChild { minSize?: number; maxSize?: number; fixed?: boolean; - hidden?: boolean; groupInfo?: GroupInfo; content: () => JSX.Element; } @@ -138,10 +137,10 @@ type GroupPanelEntry = { type: 'panel'; id: string; hidden: boolean; groupInfo: function buildGroupPanelEntries(group: PanelGroup, collapsed: boolean): GroupPanelEntry[] { const groupInfoMap = buildGroupInfoMap([{ type: 'group', group }]); - return group.panelIds.map((id) => ({ + return group.panelIds.map((id, index) => ({ type: 'panel', id, - hidden: collapsed, + hidden: collapsed && index > 0, groupInfo: groupInfoMap.get(id)!, })); } @@ -152,6 +151,52 @@ export function buildGroupPanelEntriesForTest( return buildGroupPanelEntries(group, group.collapsed); } +export function isGroupPanelHiddenForTest( + groupInfo: GroupInfo | undefined, + isCollapsed: boolean, +): boolean { + return isGroupPanelHidden(groupInfo, isCollapsed); +} + +function isGroupPanelHidden(groupInfo: GroupInfo | undefined, isCollapsed: boolean): boolean { + return !!groupInfo && 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 TilingLayout() { let containerRef: HTMLDivElement | undefined; const [hasOverflowLeft, setHasOverflowLeft] = createSignal(false); @@ -474,7 +519,6 @@ export function TilingLayout() { panelCache.set(panelId, cached); } cached.groupInfo = entry.groupInfo; - cached.hidden = entry.hidden; panels.push(cached); } } else { @@ -485,7 +529,6 @@ export function TilingLayout() { panelCache.set(panelId, cached); } cached.groupInfo = undefined; - cached.hidden = false; panels.push(cached); } } @@ -601,12 +644,19 @@ export function TilingLayout() { child: TileChild, globalIdx: number, total: number, - options?: { hideHandle?: boolean }, + 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 wrapperStyle = (): JSX.CSSProperties => { - if (child.hidden) { + if (childHidden()) { return { width: '0', 'min-width': '0', @@ -643,12 +693,12 @@ export function TilingLayout() { }; const showHandle = () => - !child.hidden && - !options?.hideHandle && + !childHidden() && + !options?.hideHandle?.() && !store.focusMode && !child.fixed && globalIdx < total - 1; - const showCollapseBtn = () => !child.hidden && !store.focusMode && child.groupInfo?.isLast; + const showCollapseBtn = () => !childHidden() && !store.focusMode && child.groupInfo?.isLast; return ( <> @@ -869,8 +919,7 @@ export function TilingLayout() { background: currentSegment.color, 'border-radius': '12px', overflow: 'hidden', - padding: groupCollapsed() ? '0' : '0 6px', - width: groupCollapsed() ? '32px' : undefined, + padding: groupWrapperPadding(groupCollapsed()), 'box-shadow': groupCollapsed() && groupHasActive() ? `inset 0 0 0 2px ${theme.accent}` @@ -889,7 +938,10 @@ export function TilingLayout() { child, currentSegment.start + localIdx(), total, - { hideHandle: isLastInGroup }, + { + hideHandle: () => + groupPanelInnerHandleHidden(groupCollapsed(), isLastInGroup), + }, ); }} @@ -911,7 +963,14 @@ export function TilingLayout() {
- {renderHandle(currentSegment.end, 'group-between-handle')} + {renderHandle( + groupResizeHandleIndex( + currentSegment.start, + currentSegment.end, + groupCollapsed(), + ), + 'group-between-handle', + )} ); diff --git a/src/styles.css b/src/styles.css index 02203e374..1e52b2a0c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1496,10 +1496,19 @@ textarea::placeholder { cursor: row-resize; } -/* Hide the center hairline on resize handles that sit inside a group - so the group background reads as one continuous surface. */ -.resize-handle-h.group-inner-handle::before { - display: none !important; +.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 { @@ -2338,15 +2347,15 @@ html[data-look^='islands-'] .group-between-handle::before { filter: brightness(0.9); } -/* Expand button inside collapsed-placeholder — fills the strip */ +/* Expand button beside the visible panel in a collapsed group. */ .panel-group-expand-btn { - position: absolute; - inset: 0; - width: 100%; + position: relative; + flex-shrink: 0; + width: 18px; height: 100%; padding: 0; border: none; - background: transparent; + background: inherit; color: inherit; cursor: pointer; display: flex; From 2dd4915bcfa67f34d3c54d6831d01a18a418d6b0 Mon Sep 17 00:00:00 2001 From: FourWindff Date: Thu, 11 Jun 2026 22:57:21 +0800 Subject: [PATCH 3/8] fix(styles): remove accent glow from active task panel - Eliminated the box-shadow effect from the active task column to enhance visual clarity. - Updated tests to verify the absence of accent outlines around collapsed active panel groups. - Ensured that the active task panel maintains consistent opacity without additional styling interference. --- src/components/TilingLayout.styles.test.ts | 18 ++++++++++++++++++ src/components/TilingLayout.tsx | 8 -------- src/styles.css | 1 - 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/components/TilingLayout.styles.test.ts b/src/components/TilingLayout.styles.test.ts index 7da439572..1301ff197 100644 --- a/src/components/TilingLayout.styles.test.ts +++ b/src/components/TilingLayout.styles.test.ts @@ -3,6 +3,10 @@ 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, '\\$&'); @@ -79,3 +83,17 @@ describe('tiling layout group collapse controls', () => { expect(expandButton.background).toBe('inherit'); }); }); + +describe('task panel active styles', () => { + 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.tsx b/src/components/TilingLayout.tsx index f5fa921d1..81ade3253 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -905,10 +905,6 @@ export function TilingLayout() { groupInfo ? isPanelGroupCollapsed(groupInfo.projectId, groupInfo.groupType) : false; - const groupHasActive = () => - panelChildren() - .slice(currentSegment.start, currentSegment.end + 1) - .some((child) => child.id === store.activeTaskId); return ( <>
Date: Thu, 11 Jun 2026 23:04:44 +0800 Subject: [PATCH 4/8] fix(styles): update button styles for improved visual consistency - Changed the background of the expand button to transparent and adjusted its hover filter for better UI coherence. - Updated related tests to ensure consistent color treatment between collapse and expand controls. - Removed inline color styling from the expand button for cleaner code and adherence to style guidelines. --- src/components/TilingLayout.styles.test.ts | 16 +++++++++++++++- src/components/TilingLayout.tsx | 1 - src/styles.css | 6 +++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/TilingLayout.styles.test.ts b/src/components/TilingLayout.styles.test.ts index 1301ff197..c5b452847 100644 --- a/src/components/TilingLayout.styles.test.ts +++ b/src/components/TilingLayout.styles.test.ts @@ -80,7 +80,21 @@ describe('tiling layout group collapse controls', () => { expect(expandButton.position).toBe('relative'); expect(expandButton.width).toBe('18px'); expect(expandButton['flex-shrink']).toBe('0'); - expect(expandButton.background).toBe('inherit'); + 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 }}'); }); }); diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index 81ade3253..e61f78420 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -942,7 +942,6 @@ export function TilingLayout() {
diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index 9754d2347..f50e1bab5 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 9688c5f25..1536bb365 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; }), ); From 3526639e85b1d8d313d716097368cffb56d6e4bf Mon Sep 17 00:00:00 2001 From: FourWindff Date: Mon, 15 Jun 2026 18:05:00 +0800 Subject: [PATCH 6/8] feat(tiling-layout): enhance panel group collapse functionality - Added `panelCount` to `GroupInfo` to track the number of panels in a group. - Implemented `isPanelGroupCollapsibleForTest` and `isPanelGroupCollapseControlVisibleForTest` functions to manage collapse behavior based on panel count. - Updated `TilingLayout` component to utilize new collapse control logic. - Enhanced tests to verify collapse behavior for single and multiple task panel groups. --- src/components/TilingLayout.test.ts | 14 ++++++++ src/components/TilingLayout.tsx | 51 +++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/components/TilingLayout.test.ts b/src/components/TilingLayout.test.ts index 305f63575..ee074bf84 100644 --- a/src/components/TilingLayout.test.ts +++ b/src/components/TilingLayout.test.ts @@ -10,6 +10,8 @@ import { groupResizeHandleIndexForTest, groupPanelInnerHandleHiddenForTest, groupWrapperPaddingForTest, + isPanelGroupCollapseControlVisibleForTest, + isPanelGroupCollapsibleForTest, isGroupPanelHiddenForTest, } from './TilingLayout'; @@ -82,6 +84,16 @@ describe('TilingLayout panel group collapse planning', () => { 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 }, @@ -95,6 +107,7 @@ describe('TilingLayout panel group collapse planning', () => { groupType: 'independent', isFirst: true, isLast: false, + panelCount: 2, color: '#334455', }, }, @@ -105,6 +118,7 @@ describe('TilingLayout panel group collapse planning', () => { groupType: 'independent', isFirst: false, isLast: true, + panelCount: 2, color: '#334455', }, }, diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index 3f08b0f04..a666d91d0 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -46,6 +46,7 @@ interface GroupInfo { groupType: PanelGroupType; isFirst: boolean; isLast: boolean; + panelCount: number; color: string; } @@ -108,6 +109,7 @@ function buildGroupInfoMap(segments: TilingSegment[]): Map { groupType: group.groupType, isFirst: i === 0, isLast: i === group.panelIds.length - 1, + panelCount: group.panelIds.length, color: bg, }); } @@ -199,8 +201,21 @@ export function isGroupPanelHiddenForTest( 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 && isCollapsed && !groupInfo.isFirst; + return ( + !!groupInfo && + isPanelGroupCollapsible(groupInfo.panelCount) && + isCollapsed && + !groupInfo.isFirst + ); } export function groupWrapperPaddingForTest(isCollapsed: boolean): string { @@ -238,6 +253,30 @@ function groupPanelInnerHandleHidden(isCollapsed: boolean, isLastInGroup: boolea 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) + ); +} + export function TilingLayout() { let containerRef: HTMLDivElement | undefined; const [hasOverflowLeft, setHasOverflowLeft] = createSignal(false); @@ -706,7 +745,13 @@ export function TilingLayout() { !store.focusMode && !child.fixed && globalIdx < total - 1; - const showCollapseBtn = () => !childHidden() && !store.focusMode && child.groupInfo?.isLast; + const showCollapseBtn = () => + isPanelGroupCollapseControlVisible( + childHidden(), + store.focusMode, + child.groupInfo?.isLast, + child.groupInfo?.panelCount, + ); return ( <> @@ -909,7 +954,7 @@ export function TilingLayout() { const firstChild = panelChildren()[currentSegment.start]; const groupInfo = firstChild?.groupInfo; const groupCollapsed = () => - groupInfo + groupInfo && isPanelGroupCollapsible(groupInfo.panelCount) ? isPanelGroupCollapsed(groupInfo.projectId, groupInfo.groupType) : false; return ( From 78659f291f85347beac7c79db4220e6d5c088ea5 Mon Sep 17 00:00:00 2001 From: FourWindff Date: Mon, 15 Jun 2026 18:17:42 +0800 Subject: [PATCH 7/8] feat(tiling-layout): optimize rendering and hidden panel styles - Introduced caching for render segments to prevent unnecessary remounting of unchanged panels. - Added `hiddenPanelContentStyle` function to manage styles for hidden panels, ensuring proper width preservation. - Enhanced tests to verify the behavior of cached segments and hidden panel styles, improving performance and UI consistency. --- src/components/TilingLayout.test.ts | 50 +++++++++++++++++++++ src/components/TilingLayout.tsx | 48 ++++++++++++++++++-- src/lib/terminalFitManager.test.ts | 69 +++++++++++++++++++++++++++++ src/lib/terminalFitManager.ts | 1 + 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 src/lib/terminalFitManager.test.ts diff --git a/src/components/TilingLayout.test.ts b/src/components/TilingLayout.test.ts index ee074bf84..87db0fb25 100644 --- a/src/components/TilingLayout.test.ts +++ b/src/components/TilingLayout.test.ts @@ -10,6 +10,7 @@ import { groupResizeHandleIndexForTest, groupPanelInnerHandleHiddenForTest, groupWrapperPaddingForTest, + hiddenPanelContentStyleForTest, isPanelGroupCollapseControlVisibleForTest, isPanelGroupCollapsibleForTest, isGroupPanelHiddenForTest, @@ -128,4 +129,53 @@ describe('TilingLayout panel group collapse planning', () => { 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 a666d91d0..795b6ec88 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -143,6 +143,19 @@ type RenderSegmentInput = { 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; @@ -164,10 +177,10 @@ function buildRenderSegments(items: RenderSegmentInput[]): RenderSegment[] { break; } } - segments.push({ type: 'group', key, start, end, color }); + segments.push(cachedRenderSegment({ type: 'group', key, start, end, color })); i = end + 1; } else { - segments.push({ type: 'single', key: `single:${item.id}`, index: i }); + segments.push(cachedRenderSegment({ type: 'single', key: `single:${item.id}`, index: i })); i++; } } @@ -277,6 +290,30 @@ function isPanelGroupCollapseControlVisible( ); } +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); @@ -701,6 +738,7 @@ export function TilingLayout() { groupInfo ? isPanelGroupCollapsed(groupInfo.projectId, groupInfo.groupType) : false, ); }; + const childSize = () => sizeFor(child); const wrapperStyle = (): JSX.CSSProperties => { if (childHidden()) { @@ -728,7 +766,7 @@ export function TilingLayout() { overflow: 'hidden', }; } - const s = sizeFor(child); + const s = childSize(); const min = child.minSize ?? 0; return { width: `${s}px`, @@ -755,7 +793,9 @@ export function TilingLayout() { return ( <> -
{child.content()}
+
+
{child.content()}
+