Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/renderer/src/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ function Terminal(): React.JSX.Element | null {
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const layoutByWorktree = useAppStore((s) => s.layoutByWorktree)
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
const maximizedGroupIdByWorktree = useAppStore((s) => s.maximizedGroupIdByWorktree)
const ensureWorktreeRootGroup = useAppStore((s) => s.ensureWorktreeRootGroup)
const reconcileWorktreeTabModel = useAppStore((s) => s.reconcileWorktreeTabModel)

Expand Down Expand Up @@ -1794,6 +1795,14 @@ function Terminal(): React.JSX.Element | null {
if (!layout) {
return null
}
const maximizedGroupIdCandidate = maximizedGroupIdByWorktree[workspace.id] ?? null
const maximizedGroupId =
maximizedGroupIdCandidate &&
(groupsByWorktree[workspace.id] ?? []).some(
(group) => group.id === maximizedGroupIdCandidate
)
? maximizedGroupIdCandidate
: null
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the tasks page too.
const isVisible =
Expand All @@ -1807,6 +1816,7 @@ function Terminal(): React.JSX.Element | null {
worktreePath={workspace.path}
layout={layout}
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
maximizedGroupId={maximizedGroupId}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
Expand Down Expand Up @@ -2085,6 +2095,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreePath,
layout,
focusedGroupId,
maximizedGroupId,
isVisible,
shouldMeasureHiddenWorktree,
activityTerminalPortals
Expand All @@ -2093,6 +2104,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreePath: string
layout: TabGroupLayoutNode
focusedGroupId?: string
maximizedGroupId?: string | null
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
Expand Down Expand Up @@ -2129,15 +2141,25 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
isWorktreeActive={isVisible}
maximizedGroupId={maximizedGroupId}
/>
<TerminalPaneOverlayLayer
worktreeId={worktreeId}
worktreePath={worktreePath}
isWorktreeActive={isVisible}
activityTerminalPortals={activityTerminalPortals}
maximizedGroupId={maximizedGroupId}
/>
<BrowserPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
maximizedGroupId={maximizedGroupId}
/>
<EmulatorPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
maximizedGroupId={maximizedGroupId}
/>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
<AiVaultSessionDropLayer worktreeId={worktreeId} enabled={isVisible} />
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,12 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
// entirely when its own props are unchanged keeps the fast path fastest.
const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({
worktreeId,
isWorktreeActive
isWorktreeActive,
maximizedGroupId = null
}: {
worktreeId: string
isWorktreeActive: boolean
maximizedGroupId?: string | null
}): React.JSX.Element {
const { browserTabs, unifiedTabs, groups } = useAppStore(
useShallow((state) => ({
Expand Down Expand Up @@ -200,7 +202,12 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({
<>
{browserTabs.map((browserTab) => {
const assignment = assignments.get(browserTab.id)
const isActive = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const isActive = Boolean(
isWorktreeActive &&
assignment &&
assignment.isActiveInGroup &&
(maximizedGroupId === null || assignment.groupId === maximizedGroupId)
)
return (
<BrowserOverlaySlot
key={browserTab.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,12 @@ const SimulatorOverlaySlot = memo(function SimulatorOverlaySlot({

const EmulatorPaneOverlayLayer = memo(function EmulatorPaneOverlayLayer({
worktreeId,
isWorktreeActive
isWorktreeActive,
maximizedGroupId = null
}: {
worktreeId: string
isWorktreeActive: boolean
maximizedGroupId?: string | null
}): React.JSX.Element {
const { unifiedTabs, groups } = useAppStore(
useShallow((state) => ({
Expand Down Expand Up @@ -91,7 +93,11 @@ const EmulatorPaneOverlayLayer = memo(function EmulatorPaneOverlayLayer({
<>
{simulatorTabs.map((tab) => {
const isActiveInGroup = groupActiveTabById[tab.groupId] === tab.id
const isActive = Boolean(isWorktreeActive && isActiveInGroup)
const isActive = Boolean(
isWorktreeActive &&
isActiveInGroup &&
(maximizedGroupId === null || tab.groupId === maximizedGroupId)
)
return (
<SimulatorOverlaySlot
key={tab.id}
Expand Down
67 changes: 66 additions & 1 deletion src/renderer/src/components/tab-group/TabGroupPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Suspense, useMemo } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { useDroppable } from '@dnd-kit/core'
import { Ellipsis, X } from 'lucide-react'
import { Ellipsis, Maximize2, Minimize2, X } from 'lucide-react'
import { useAppStore } from '../../store'
import {
DropdownMenu,
Expand Down Expand Up @@ -256,6 +256,51 @@ export default function TabGroupPanel({
{isFocused ? (
<TabBarQuickCommandsButton worktreeId={worktreeId} groupId={groupId} />
) : null}
{/* Why: maximizing a single-pane group is a no-op; spanning only matters
when there are sibling split panes to hide. */}
{isFocused && hasSplitGroups ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-pressed={model.isGroupMaximized}
aria-label={
model.isGroupMaximized
? translate(
'auto.components.tab.group.TabGroupPanel.restoreSplitLayout',
'Restore split layout'
)
: translate(
'auto.components.tab.group.TabGroupPanel.maximizePane',
'Maximize pane'
)
}
onClick={(event) => {
event.stopPropagation()
commands.toggleGroupMaximize()
}}
className={menuButtonClassName}
>
{model.isGroupMaximized ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{model.isGroupMaximized
? translate(
'auto.components.tab.group.TabGroupPanel.restoreSplitLayout',
'Restore split layout'
)
: translate(
'auto.components.tab.group.TabGroupPanel.maximizePane',
'Maximize pane'
)}
</TooltipContent>
</Tooltip>
) : null}
{isFocused && hasSplitGroups ? (
<Tooltip>
<DropdownMenu modal={false}>
Expand All @@ -277,6 +322,26 @@ export default function TabGroupPanel({
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={4}>
<DropdownMenuItem
onSelect={() => {
commands.toggleGroupMaximize()
}}
>
{model.isGroupMaximized ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
{model.isGroupMaximized
? translate(
'auto.components.tab.group.TabGroupPanel.restoreSplitLayout',
'Restore split layout'
)
: translate(
'auto.components.tab.group.TabGroupPanel.maximizePane',
'Maximize pane'
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,33 @@ describe('TabGroupSplitLayout', () => {

expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes')
})

it('renders only the maximized group while preserving split affordance context', () => {
const element = TabGroupSplitLayout({
layout: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'left-group' },
second: { type: 'leaf', groupId: 'right-group' }
},
worktreeId: 'wt-1',
focusedGroupId: 'right-group',
isWorktreeActive: true,
maximizedGroupId: 'right-group'
})

const tabGroupPanelElement = asElement(getSplitNodeElement(element))

expect(tabGroupPanelElement.props).toEqual(
expect.objectContaining({
groupId: 'right-group',
worktreeId: 'wt-1',
isFocused: true,
hasSplitGroups: true,
reserveClosedExplorerToggleSpace: true,
reserveCollapsedSidebarHeaderSpace: true
})
)
})
})
20 changes: 18 additions & 2 deletions src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import { type HoveredTabInsertion, useTabDragSplit } from './useTabDragSplit'
const MIN_RATIO = 0.15
const MAX_RATIO = 0.85

function findLeafForGroup(
node: TabGroupLayoutNode,
groupId: string
): Extract<TabGroupLayoutNode, { type: 'leaf' }> | null {
if (node.type === 'leaf') {
return node.groupId === groupId ? node : null
}
return findLeafForGroup(node.first, groupId) ?? findLeafForGroup(node.second, groupId)
}

function ResizeHandle({
direction,
onResizeStart,
Expand Down Expand Up @@ -213,15 +223,21 @@ export default function TabGroupSplitLayout({
layout,
worktreeId,
focusedGroupId,
isWorktreeActive
isWorktreeActive,
maximizedGroupId
}: {
layout: TabGroupLayoutNode
worktreeId: string
focusedGroupId?: string
isWorktreeActive: boolean
maximizedGroupId?: string | null
}): React.JSX.Element {
const dragSplit = useTabDragSplit({ worktreeId, enabled: isWorktreeActive })
const hasSplits = layout.type === 'split'
// Why: split chrome is derived from the full layout while maximize only swaps
// the mounted leaf below, so restore affordances and edge borders stay true.
const maximizedLeaf = maximizedGroupId ? findLeafForGroup(layout, maximizedGroupId) : null
const renderedLayout = maximizedLeaf ?? layout

return (
<TabDragProvider
Expand Down Expand Up @@ -266,7 +282,7 @@ export default function TabGroupSplitLayout({
<div className="h-[4px] shrink-0 bg-card" data-terminal-focus-release-surface="true" />
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
<SplitNode
node={layout}
node={renderedLayout}
nodePath=""
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const mocks = vi.hoisted(() => ({
setActiveTabType: vi.fn(),
setActiveWorktree: vi.fn(),
setTabColor: vi.fn(),
setTabCustomTitle: vi.fn()
setTabCustomTitle: vi.fn(),
toggleMaximizedTabGroup: vi.fn()
}))

const storeBox = vi.hoisted(() => ({
Expand Down Expand Up @@ -119,6 +120,7 @@ function resetStore(): void {
activeWorktreeId: 'wt-1',
browserTabsByWorktree: {},
expandedPaneByTabId: {},
maximizedGroupIdByWorktree: {},
groupsByWorktree: {
'wt-1': [
{
Expand Down Expand Up @@ -156,7 +158,8 @@ function resetStore(): void {
setActiveTabType: mocks.setActiveTabType,
setActiveWorktree: mocks.setActiveWorktree,
setTabColor: mocks.setTabColor,
setTabCustomTitle: mocks.setTabCustomTitle
setTabCustomTitle: mocks.setTabCustomTitle,
toggleMaximizedTabGroup: mocks.toggleMaximizedTabGroup
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function useTabGroupWorkspaceModel({
openFiles: state.openFiles,
browserTabs: state.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
expandedPaneByTabId: state.expandedPaneByTabId,
maximizedGroupId: state.maximizedGroupIdByWorktree[worktreeId] ?? null,
terminalLayoutsByTabId: state.terminalLayoutsByTabId ?? EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID,
generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true,
mobileEmulatorEnabled: state.settings?.mobileEmulatorEnabled !== false
Expand Down Expand Up @@ -102,6 +103,7 @@ export function useTabGroupWorkspaceModel({
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const toggleMaximizedTabGroup = useAppStore((state) => state.toggleMaximizedTabGroup)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)

Expand Down Expand Up @@ -586,6 +588,7 @@ export function useTabGroupWorkspaceModel({
tabBarOrder,
groupTabs,
expandedPaneByTabId: worktreeState.expandedPaneByTabId,
isGroupMaximized: worktreeState.maximizedGroupId === groupId,
commands: {
focusGroup: () => {
focusGroup(worktreeId, groupId)
Expand All @@ -599,6 +602,9 @@ export function useTabGroupWorkspaceModel({
closeOthers,
closeToRight,
createSplitGroup,
toggleGroupMaximize: () => {
toggleMaximizedTabGroup(worktreeId, groupId)
},
newBrowserTab: () => {
void openNewBrowserTabInActiveWorkspace(groupId)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,14 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
worktreePath,
isWorktreeActive,
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS,
maximizedGroupId = null
}: {
worktreeId: string
worktreePath: string
isWorktreeActive: boolean
activityTerminalPortals?: ActivityTerminalPortalTarget[]
maximizedGroupId?: string | null
}): React.JSX.Element | null {
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
useShallow((state) => ({
Expand Down Expand Up @@ -354,7 +356,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
<>
{terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const isVisible = Boolean(
isWorktreeActive &&
assignment &&
assignment.isActiveInGroup &&
(maximizedGroupId === null || assignment.groupId === maximizedGroupId)
)
const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId)
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
worktreeId,
Expand Down
Loading