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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions src/renderer/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,6 @@ export default function App() {
showStatusMode={settings.showStatusMode}
showWorktreeBadges={settings.showWorktreeBadges}
/>
<NewAgentPlacementOverlay
open={newAgentPlacementOpen}
workspace={workspace}
onClose={closeNewAgentPlacement}
/>
</div>
) : activeTab ? (
<div className="relative h-full min-h-0 min-w-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const paneCommands: CommandDef[] = [
id: 'new-agent',
title: 'New Agent…',
keywords: ['new', 'agent', 'placement', 'claude', 'codex', 'terminal'],
when: ({ workspace }) => Boolean(workspace.activeTab && !workspace.tileTabs),
when: ({ workspace }) => Boolean(workspace.activeTab && !workspace.tileTabs && !workspace.dispatchMode),
run: ({ workspace }) => workspace.startNewAgentPlacement(),
},
{
Expand Down
116 changes: 29 additions & 87 deletions src/renderer/src/features/workspace/lib/newAgentPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,6 @@ export type PlacementTarget =
scope: 'global'
}

type Center = {
x: number
y: number
}

function centerOf(rect: Rect): Center {
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
}
}

export function buildPlacementTargets(
root: TileNode,
anchorSessionId: SessionId,
Expand Down Expand Up @@ -122,14 +110,15 @@ export function buildPlacementTargets(
},
]

// WHY generate explicit placement targets instead of inferring from
// arrow "intent":
// WHY keep explicit placement target records instead of deriving the
// commit operation directly from the key press:
//
// The old model guessed from the focused pane alone, which breaks down as
// soon as the user wants to insert relative to the whole layout. Example:
// with two stacked rows, "new bottom row" should wrap the entire root, not
// try to synthesize something from the focused leaf. These targets are real
// layout operations, so the preview and commit path can stay identical.
// The placement UI shows ONE preview at a time now, but the selected
// preview still has to carry the real layout operation to commit. A
// plain arrow maps to a split-leaf target; Shift+arrow maps to a
// wrap-root target. Keeping those as addressable records means preview
// geometry and commit payload stay identical, instead of rebuilding
// "left/right/top/bottom" meaning in two different places.
return dedupeTargets(targets)
}

Expand All @@ -146,11 +135,11 @@ function dedupeTargets(targets: PlacementTarget[]): PlacementTarget[] {
// care about; dropping the rounding collision trap also drops the
// silent "which sibling wins?" non-determinism.
//
// We still keep a dedupe pass (rather than trusting callers) because
// wrap-root and split-leaf can generate the same id in edge cases
// where the anchor IS the root leaf and "left of focused pane" is
// visually equivalent to "new left column". Same-id targets are
// genuinely redundant and the first one wins.
// In the one-leaf case, global and local placements can be visually
// identical, but their ids remain intentionally different because
// they represent different commands from the UI's perspective
// (Shift+arrow vs plain arrow). The pass remains a cheap guard
// against accidental duplicate records from future target builders.
const seen = new Set<string>()
const out: PlacementTarget[] = []
for (const target of targets) {
Expand All @@ -177,70 +166,23 @@ export function defaultPlacementTargetId(
)
}

export function findNearestPlacementTarget(
export function placementTargetIdForArrow(
targets: PlacementTarget[],
selectedId: string | null,
direction: 'left' | 'right' | 'up' | 'down',
anchorSessionId: SessionId,
arrow: 'left' | 'right' | 'up' | 'down',
scope: 'local' | 'global',
): string | null {
const current =
targets.find(target => target.id === selectedId) ??
targets[0]
if (!current) return null

const currentCenter = centerOf(current.rect)
const candidates = targets
.filter(target => target.id !== current.id)
.map(target => ({
target,
center: centerOf(target.rect),
}))
.filter(candidate => {
if (direction === 'left') return candidate.center.x < currentCenter.x - 1
if (direction === 'right') return candidate.center.x > currentCenter.x + 1
if (direction === 'up') return candidate.center.y < currentCenter.y - 1
return candidate.center.y > currentCenter.y + 1
})

if (candidates.length === 0) return current.id

const ranked = candidates.sort((a, b) => {
const primaryA = primaryAxisDistance(currentCenter, a.center, direction)
const primaryB = primaryAxisDistance(currentCenter, b.center, direction)
if (primaryA !== primaryB) return primaryA - primaryB

const crossA = crossAxisDistance(currentCenter, a.center, direction)
const crossB = crossAxisDistance(currentCenter, b.center, direction)
if (crossA !== crossB) return crossA - crossB
const direction: SplitDirection =
arrow === 'left' || arrow === 'right' ? 'vertical' : 'horizontal'
const side: 'a' | 'b' =
arrow === 'left' || arrow === 'up' ? 'a' : 'b'

return euclideanDistance(currentCenter, a.center) - euclideanDistance(currentCenter, b.center)
})

return ranked[0]?.target.id ?? current.id
}

function primaryAxisDistance(
from: Center,
to: Center,
direction: 'left' | 'right' | 'up' | 'down',
): number {
if (direction === 'left') return from.x - to.x
if (direction === 'right') return to.x - from.x
if (direction === 'up') return from.y - to.y
return to.y - from.y
}

function crossAxisDistance(
from: Center,
to: Center,
direction: 'left' | 'right' | 'up' | 'down',
): number {
return direction === 'left' || direction === 'right'
? Math.abs(to.y - from.y)
: Math.abs(to.x - from.x)
}

function euclideanDistance(a: Center, b: Center): number {
const dx = a.x - b.x
const dy = a.y - b.y
return Math.hypot(dx, dy)
return (
targets.find(target => {
if (target.direction !== direction || target.side !== side) return false
if (scope === 'global') return target.kind === 'wrap-root'
return target.kind === 'split-leaf' && target.targetSessionId === anchorSessionId
})?.id ??
defaultPlacementTargetId(targets, anchorSessionId)
)
}
97 changes: 52 additions & 45 deletions src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import {
buildPlacementTargets,
defaultPlacementTargetId,
findNearestPlacementTarget,
placementTargetIdForArrow,
type PlacementTarget,
} from '@renderer/features/workspace/lib/newAgentPlacement'
import type { SessionKind } from '@renderer/workspace/types'
Expand All @@ -21,6 +21,13 @@ const KIND_OPTIONS: Array<{ kind: SessionKind; label: string; description: strin
{ kind: 'terminal', label: 'Terminal', description: 'plain shell pane' },
]

const ARROW_TO_DIRECTION = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
} as const

export function NewAgentPlacementOverlay({ open, workspace, onClose }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
Expand Down Expand Up @@ -141,24 +148,32 @@ export function NewAgentPlacementOverlay({ open, workspace, onClose }: Props) {
}
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedTargetId(prev => findNearestPlacementTarget(placementTargets, prev, 'up'))
return
}
if (event.key === 'ArrowDown') {
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.preventDefault()
setSelectedTargetId(prev => findNearestPlacementTarget(placementTargets, prev, 'down'))
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
setSelectedTargetId(prev => findNearestPlacementTarget(placementTargets, prev, 'left'))
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
setSelectedTargetId(prev => findNearestPlacementTarget(placementTargets, prev, 'right'))
// WHY direct arrow mapping instead of nearest-rectangle navigation:
//
// The target set intentionally contains two different operations:
// split the focused pane, or wrap the whole root. Rendering every
// target as a clickable rectangle made those operations overlap, and
// center-distance navigation could jump from a local split to an
// unrelated outer row because a large half-screen target happened to
// be closer. Plain arrows now mean "place relative to the focused
// pane"; Shift+arrow means "place relative to the whole tab." That
// keeps the operations explicit and makes the preview the only visual
// source of truth.
const arrow = ARROW_TO_DIRECTION[event.key]
const scope = event.shiftKey ? 'global' : 'local'
setSelectedTargetId(placementTargetIdForArrow(
placementTargets,
anchorSessionId,
arrow,
scope,
))
return
}
if (event.key === 'Enter' && placementTarget) {
Expand Down Expand Up @@ -196,32 +211,24 @@ export function NewAgentPlacementOverlay({ open, workspace, onClose }: Props) {
ref={overlayRef}
className="absolute inset-0 z-40 bg-black/20"
>
{selectedKind && placementTargets.map(target => {
const active = target.id === placementTarget?.id
return (
<button
key={target.id}
type="button"
onClick={() => setSelectedTargetId(target.id)}
className={`absolute border pointer-events-auto ${
active
? 'border-red-500 bg-red-500/12 shadow-[0_0_0_1px_rgba(239,68,68,0.35)]'
: target.scope === 'global'
? 'border-white/20 bg-white/5 hover:border-white/35'
: 'border-white/15 bg-white/[0.03] hover:border-white/25'
}`}
style={{
left: target.rect.x,
top: target.rect.y,
width: target.rect.width,
height: target.rect.height,
}}
aria-label={target.label}
>
<span className="sr-only">{target.label}</span>
</button>
)
})}
{selectedKind && placementTarget && (
<div
className={`
absolute pointer-events-none border-2
${placementTarget.scope === 'global'
? 'border-amber-400 bg-amber-400/12 shadow-[0_0_0_1px_rgba(251,191,36,0.35)]'
: 'border-red-500 bg-red-500/12 shadow-[0_0_0_1px_rgba(239,68,68,0.35)]'
}
`}
style={{
left: placementTarget.rect.x,
top: placementTarget.rect.y,
width: placementTarget.rect.width,
height: placementTarget.rect.height,
}}
aria-label={placementTarget.label}
/>
)}

<div className="absolute left-4 top-4 pointer-events-none">
<div className="border border-border bg-surface/95 px-3 py-2 text-[11px] text-ink-dim shadow-lg shadow-black/30">
Expand All @@ -231,7 +238,7 @@ export function NewAgentPlacementOverlay({ open, workspace, onClose }: Props) {
<div className="space-y-1">
<div>{KIND_OPTIONS.find(option => option.kind === selectedKind)?.label} placement</div>
<div className="text-muted">
Arrow keys move between real layout targets. Enter confirms. Backspace resets.
Arrows split the focused pane. Shift+arrows add an outer row or column.
</div>
<div className="text-muted">
Target: {placementTarget?.label ?? 'none'}
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/src/workspace/hook/actions/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function useDispatchActions(
setTileTabs: WorkspaceSetTileTabs,
refs: WorkspaceRefs,
showToast: (message: string, durationMs?: number) => void,
closeNewAgentPlacement: () => void,
sessionActions: SessionActions,
): {
enterDispatchMode: (scope?: DispatchModeState['scope']) => Promise<void>
Expand Down Expand Up @@ -107,6 +108,7 @@ export function useDispatchActions(

const enterDispatchMode = useCallback(
async (scope: DispatchModeState['scope'] = state.dispatchMode?.scope ?? 'project') => {
closeNewAgentPlacement()
setState(prev => ({
...prev,
dispatchMode: {
Expand All @@ -117,7 +119,7 @@ export function useDispatchActions(
setTileTabs(null)
await ensureDispatchTerminal()
},
[ensureDispatchTerminal, setState, setTileTabs, state.dispatchMode?.scope],
[closeNewAgentPlacement, ensureDispatchTerminal, setState, setTileTabs, state.dispatchMode?.scope],
)

const exitDispatchMode = useCallback(() => {
Expand All @@ -129,6 +131,7 @@ export function useDispatchActions(

const setDispatchScope = useCallback(
async (scope: DispatchModeState['scope']) => {
closeNewAgentPlacement()
setState(prev => ({
...prev,
dispatchMode: {
Expand All @@ -138,7 +141,7 @@ export function useDispatchActions(
}))
await ensureDispatchTerminal()
},
[ensureDispatchTerminal, setState],
[closeNewAgentPlacement, ensureDispatchTerminal, setState],
)

const toggleDispatchTerminal = useCallback(async () => {
Expand Down
14 changes: 12 additions & 2 deletions src/renderer/src/workspace/hook/actions/pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback } from 'react'

import type {
BuriedPaneRecord,
DispatchModeState,
SessionId,
SessionKind,
SessionMeta,
Expand Down Expand Up @@ -40,7 +41,12 @@ import type { SessionActions } from '@renderer/workspace/hook/actions/session'
// -----------------------------------------------------------------------------

export function usePaneActions(
state: { activeTabId: string; sessions: Record<SessionId, SessionMeta>; tabs: Tab[] },
state: {
activeTabId: string
dispatchMode: DispatchModeState | null
sessions: Record<SessionId, SessionMeta>
tabs: Tab[]
},
setState: WorkspaceSetState,
setRuntimes: WorkspaceSetRuntimes,
setSpotlight: WorkspaceSetSpotlight,
Expand Down Expand Up @@ -110,8 +116,12 @@ export function usePaneActions(
const startNewAgentPlacement = useCallback(() => {
const tab = state.tabs.find(t => t.id === state.activeTabId)
if (!tab) return
if (state.dispatchMode) {
showToast('New Agent placement is only available in the tiled workspace')
return
}
openNewAgentPlacement()
}, [openNewAgentPlacement, state.activeTabId, state.tabs])
}, [openNewAgentPlacement, showToast, state.activeTabId, state.dispatchMode, state.tabs])

const commitNewAgentPlacement = useCallback(
async (kind: SessionKind, target: PlacementTarget) => {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/workspace/hook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export function useWorkspace(
setTileTabs,
refs,
showToast,
closeNewAgentPlacement,
sessionActions,
)

Expand Down