Merge pull request #74734 from NousResearch/bb/tab-context-menu

Every tab strip gets the standard right-click tab menu
This commit is contained in:
brooklyn! 2026-07-30 05:23:06 -05:00 committed by GitHub
commit 206eda50a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 89 additions and 283 deletions

View file

@ -320,91 +320,6 @@ export function groupLeafIds(node: LayoutNode): string[] {
return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds)
}
function pathToGroup(node: LayoutNode, groupId: string): LayoutNode[] | null {
if (node.type === 'group') {
return node.id === groupId ? [node] : null
}
for (const child of node.children) {
const sub = pathToGroup(child, groupId)
if (sub) {
return [node, ...sub]
}
}
return null
}
const OPPOSITE_EDGE: Record<RootEdge, RootEdge> = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' }
/** The viable group touching `edge` of this subtree. Along the edge's axis
* children are scanned edge-first a non-viable zone is display:none, so the
* next sibling IS the visual edge; across it, every child touches the edge. */
function edgeGroup(node: LayoutNode, edge: RootEdge, viable: (g: GroupNode) => boolean): GroupNode | null {
if (node.type === 'group') {
return viable(node) ? node : null
}
const along = (node.orientation === 'row') === (edge === 'left' || edge === 'right')
const children = along && (edge === 'right' || edge === 'bottom') ? [...node.children].reverse() : node.children
for (const child of children) {
const hit = edgeGroup(child, edge, viable)
if (hit) {
return hit
}
}
return null
}
/**
* The viable zone VISUALLY adjacent to `groupId` on `side` (the target of the
* zone menu's "Move left/right/up/down"). Walks up to the nearest ancestor
* split running along that axis with a sibling on that side, then descends to
* the sibling's closest viable leaf; subtrees whose every zone fails `viable`
* (all panes hidden) are skipped, matching their collapsed rendering.
*/
export function adjacentGroup(
root: LayoutNode,
groupId: string,
side: RootEdge,
viable: (g: GroupNode) => boolean
): GroupNode | null {
const path = pathToGroup(root, groupId)
if (!path) {
return null
}
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
const forward = side === 'right' || side === 'bottom'
for (let i = path.length - 2; i >= 0; i--) {
const parent = path[i]
if (parent.type !== 'split' || parent.orientation !== orientation) {
continue
}
const index = parent.children.indexOf(path[i + 1])
const siblings = forward ? parent.children.slice(index + 1) : parent.children.slice(0, index).reverse()
for (const sibling of siblings) {
const hit = edgeGroup(sibling, OPPOSITE_EDGE[side], viable)
if (hit) {
return hit
}
}
}
return null
}
function sameSet(ids: string[], set: Set<string>): boolean {
return ids.length === set.size && ids.every(id => set.has(id))
}
@ -523,32 +438,6 @@ function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => Layou
return { ...node, children: node.children.map(c => replaceNode(c, id, make)) }
}
/**
* Split a zone: `movePaneId` (one of SEVERAL panes in the group) moves into
* the new zone on `side` VS Code "split right", split and move in one
* gesture. A lone pane can't split away from itself: no-op (normalize prunes
* the empty zone the split would have minted).
*/
export function splitGroupZone(root: LayoutNode, groupId: string, side: RootEdge, movePaneId: string): LayoutNode {
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
const before = side === 'left' || side === 'top'
return (
normalize(
replaceNode(root, groupId, g => {
if (g.panes.length < 2 || !g.panes.includes(movePaneId)) {
return g
}
const added = group([movePaneId])
const remaining = { ...g, panes: g.panes.filter(p => p !== movePaneId) }
return split(orientation, before ? [added, remaining] : [remaining, added], [1, 1])
})
) ?? root
)
}
/** Mirror the layout HORIZONTALLY (the titlebar flip toggle / \): reverse
* every ROW split's child order at EVERY depth, so leftright flips
* everywhere. A right rail lands on the left with its OWN internal order

View file

@ -6,14 +6,14 @@
*
* Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and
* every zone lights up as a whole-region drop target. Right-click opens the
* contextual zone menu (split/move + header/minimize toggles).
* contextual zone menu (tab close verbs + header/minimize toggles).
*/
import { useStore } from '@nanostores/react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { DecodeText } from '@/components/ui/decode-text'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import { PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
@ -25,28 +25,28 @@ import { cn } from '@/lib/utils'
import { $layoutEditMode } from '../../edit-mode'
import { useWindowControlsOverlap } from '../../geometry'
import { hiddenPaneProps, PaneGroupContext, PaneVisibleContext } from '../../pane-visibility'
import type { DropPosition, GroupNode, RootEdge } from '../model'
import { adjacentGroup } from '../model'
import type { DropPosition, GroupNode } from '../model'
import {
$dropHint,
$hiddenTreePanes,
$layoutTree,
$narrowViewport,
$newSessionTabAction,
$treeDragging,
activateTreePane,
closeAllTreeTabs,
closeOtherTreeTabs,
closeTreePane,
closeTreeTabsToRight,
collapseTreePane,
dismissTreePane,
isCollapsePane,
isSessionStripPane,
moveTreePane,
noteActiveTreeGroup,
restoreTreePane,
SESSION_TILE_DRAG,
setTreeGroupHeaderHidden,
setTreeGroupMinimized,
splitTreeZone
treeTabCloseTargets
} from '../store'
import { type DoubleTapContext, startPaneDrag } from './drag-session'
@ -54,30 +54,19 @@ import { forceLoneHeaderForPanes } from './lone-header'
import { useActiveTabVisible } from './tab-strip-scroll'
import { paneChrome } from './track-model'
/** A directional action in the zone menu (computed per group state). */
interface ZoneMenuDirection {
side: RootEdge
label: string
run: () => void
}
const DIRECTION_ORDER: readonly RootEdge[] = ['right', 'bottom', 'left', 'top']
const DIRECTION_ARROW: Record<RootEdge, string> = { bottom: '↓', left: '←', right: '→', top: '↑' }
/** Right-click zone menu: directional actions + header toggle + minimize.
* The directions are CONTEXTUAL (computed by TreeGroup): a stacked group
* offers "Split <dir>" (carve a new zone with the clicked pane VS Code
* split-and-move in one gesture); a single-pane group offers "Move <dir>"
* into the zone actually sitting on that side directions with no visible
* neighbor aren't offered, so no action ever appears to do nothing. */
/** Right-click zone menu: the tab verbs (close this / others / to the right /
* all) plus the strip's own chrome toggles. Same items and icons as a session
* tab's menu, so every tab in a strip answers a right-click the same way
* a pane with no domain menu of its own (the file tree, a terminal, the main
* tab on a fresh draft) falls through to this one. */
function ZoneMenu({
children,
closable,
minimizable = true,
directions,
headerHidden,
minimized,
nodeId
nodeId,
targetPane
}: {
children: ReactNode
/** The pane the menu closes (the right-clicked chip / the active pane);
@ -86,52 +75,71 @@ function ZoneMenu({
/** False for the zone hosting the uncloseable workspace collapsing the
* MAIN pane strands the app behind a strip. */
minimizable?: boolean
/** Called when the menu renders, not on every zone re-render: resolving the
* neighbor zones has to read the layout tree, and subscribing every zone to
* it made a sash drag re-render every mounted pane. Same lazy shape as
* `closable`. */
directions: () => ZoneMenuDirection[]
headerHidden?: boolean
minimized?: boolean
nodeId: string
/** The right-clicked chip (else the active pane) — what the close-others /
* to-the-right / all verbs measure from. Called when the menu RENDERS, not
* on every zone re-render: resolving the siblings reads the layout tree,
* and subscribing every zone to it made a sash drag re-render every
* mounted pane. */
targetPane: () => string
}) {
const { t } = useI18n()
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{directions().map(direction => (
<ContextMenuItem key={direction.side} onSelect={direction.run}>
{direction.label}
</ContextMenuItem>
))}
<ContextMenuItem onSelect={() => setTreeGroupHeaderHidden(nodeId, !headerHidden)}>
{headerHidden ? t.zones.showHeader : t.zones.hideHeader}
</ContextMenuItem>
{minimizable && (
<ContextMenuItem onSelect={() => setTreeGroupMinimized(nodeId, !minimized)}>
{minimized ? t.zones.restore : t.zones.minimize}
</ContextMenuItem>
)}
{/* Resolved at render: the menu mounts on open, after the right-click
set menuPane so an uncloseable target hides the item instead
of offering a dead action. */}
{closable?.() !== undefined && (
<ContextMenuItem
onSelect={() => {
const paneId = closable?.()
// Resolved at render: the menu mounts on open, after the right-click set
// menuPane — so an uncloseable target hides Close instead of offering a
// dead action, and the counts describe the chip actually clicked.
const items = (kit: MenuKit) => {
const paneId = closable?.()
const targets = treeTabCloseTargets(targetPane())
if (paneId) {
closeTreePane(paneId)
}
}}
>
{t.common.close}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
return (
<>
{paneId !== undefined &&
renderActionItem(kit, {
icon: 'close',
label: t.common.close,
onSelect: () => closeTreePane(paneId)
})}
{renderActionItem(kit, {
disabled: !targets.others,
icon: 'close-all',
label: t.zones.closeOthers,
onSelect: () => closeOtherTreeTabs(targetPane())
})}
{renderActionItem(kit, {
disabled: !targets.right,
icon: 'arrow-right',
label: t.zones.closeToRight,
onSelect: () => closeTreeTabsToRight(targetPane())
})}
{renderActionItem(kit, {
disabled: !targets.all,
icon: 'clear-all',
label: t.zones.closeAll,
onSelect: () => closeAllTreeTabs(targetPane())
})}
<kit.Separator />
{renderActionItem(kit, {
icon: headerHidden ? 'eye' : 'eye-closed',
label: headerHidden ? t.zones.showHeader : t.zones.hideHeader,
onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden)
})}
{minimizable &&
renderActionItem(kit, {
icon: minimized ? 'chevron-down' : 'chevron-up',
label: minimized ? t.zones.restore : t.zones.minimize,
onSelect: () => setTreeGroupMinimized(nodeId, !minimized)
})}
</>
)
}
return (
<ActionsContextMenu contentClassName="w-40" items={items}>
{children}
</ActionsContextMenu>
)
}
@ -255,61 +263,20 @@ export function TreeGroup({
}
}
const dirWord: Record<RootEdge, string> = {
bottom: t.zones.dirDown,
left: t.zones.dirLeft,
right: t.zones.dirRight,
top: t.zones.dirUp
}
// Zone-menu directions, contextual to this group's state:
// - stacked panes -> "Split <dir>": carve a new zone on that side with the
// right-clicked chip's pane in it (split + move, one gesture);
// - a single pane -> "Move <dir>": join the zone visually adjacent on that
// side (splitting here would only make an invisible empty zone). Sides
// with no visible neighbor are omitted entirely.
// NOT `useStore($layoutTree)`: this subscribes every zone — and therefore
// every mounted pane and its whole transcript — to the entire layout tree.
// A sash drag rewrites the tree once per frame, so dragging the sidebar
// re-rendered all five tiles' message lists on every pointermove (measured:
// TreeGroup 180 renders cascading into ChatView/Thread/TileChat at ~4.5s
// each, holding the drag at ~3fps).
//
// The tree is only read to build the zone context menu's move/split
// directions, which are consumed when the menu OPENS — so read it at that
// moment with `.get()` instead of subscribing to every intermediate frame.
const menuDirections = (): ZoneMenuDirection[] => {
if (shown.length > 1) {
return DIRECTION_ORDER.map(side => ({
side,
label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => splitTreeZone(node.id, side, menuPane ?? activeId)
}))
}
const tree = $layoutTree.get()
return DIRECTION_ORDER.flatMap(side => {
const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null
if (!neighbor || neighbor.id === node.id) {
return []
}
return [
{
side,
label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' })
}
]
})
}
// Zone-menu close targets read the layout tree, but this component must NOT
// subscribe to it: `useStore($layoutTree)` here wires every zone — and
// therefore every mounted pane and its whole transcript — to the entire
// tree. A sash drag rewrites the tree once per frame, so dragging the
// sidebar re-rendered all five tiles' message lists on every pointermove
// (measured: TreeGroup 180 renders cascading into ChatView/Thread/TileChat
// at ~4.5s each, holding the drag at ~3fps). The menu's items are resolved
// when it OPENS, so they read the tree with `.get()` at that moment instead.
const targetPane = () => menuPane ?? activeId
// Close targets the right-clicked chip (falling back to the active pane);
// only panes that declare `uncloseable` (the main workspace) are exempt.
const closable = () => {
const paneId = menuPane ?? activeId
const paneId = targetPane()
return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId
}
@ -330,11 +297,11 @@ export function TreeGroup({
// Same menu on the header strip and the edit veil — one prop bag.
const zoneMenu = {
closable,
directions: menuDirections,
headerHidden,
minimizable,
minimized: node.minimized,
nodeId: node.id
nodeId: node.id,
targetPane
}
// NO body double-click toggle: virtualized content (the thread) recreates

View file

@ -31,12 +31,10 @@ import {
normalize,
removePane,
reorderPaneInGroup as reorderPaneInGroupOp,
type RootEdge,
setActivePane as setActivePaneOp,
setGroupHeaderHidden as setGroupHeaderHiddenOp,
setGroupMinimized,
setSplitWeights as setSplitWeightsOp,
splitGroupZone as splitGroupZoneOp,
type SplitNode
} from './model'
import { FLOATING_PLACEMENT } from './renderer/floating-rect'
@ -1205,18 +1203,6 @@ export function reorderTreePane(groupId: string, paneId: string, toIndex: number
}
}
/** Split a zone on `side`, moving `movePaneId` out of its stack into the new
* zone (VS Code split-and-move the zone menu's Split actions). */
export function splitTreeZone(groupId: string, side: RootEdge, movePaneId: string) {
const tree = $layoutTree.get()
if (tree) {
commit(splitGroupZoneOp(tree, groupId, side, movePaneId))
markActivePreset('custom')
markPaneUserPlaced(movePaneId)
}
}
export function setTreeGroupMinimized(groupId: string, minimized: boolean) {
const tree = $layoutTree.get()

View file

@ -2237,17 +2237,11 @@ export const ar = defineLocale({
closeOthers: 'إغلاق الأخرى',
closeToRight: 'إغلاق ما على اليمين',
closeAll: 'إغلاق الكل',
split: dir => `تقسيم ${dir}`,
move: dir => `نقل ${dir}`,
dirUp: 'للأعلى',
dirDown: 'للأسفل',
dirLeft: 'لليسار',
dirRight: 'لليمين',
pluginDisabled: pluginId => `الإضافة "${pluginId}" معطلة`,
pluginDisabledBody: 'أعد تفعيلها من الإعدادات ← الإضافات لإرجاع اللوحة.',
missingPane: paneId => `لوحة مفقودة: ${paneId}`,
editTitle: 'التخطيطات',
editHint: 'اختر تخطيطا، أو اسحب اللوحات بين المناطق. انقر بزر الفأرة الأيمن على منطقة لتقسيمها.',
editHint: 'اختر تخطيطا، أو اسحب اللوحات بين المناطق.',
reset: 'إعادة ضبط',
templates: 'القوالب',
custom: 'مخصص',

View file

@ -2657,17 +2657,11 @@ export const en: Translations = {
closeToRight: 'Close to the right',
closeAll: 'Close all',
newSessionTab: 'New session tab',
split: dir => `Split ${dir}`,
move: dir => `Move ${dir}`,
dirUp: 'up',
dirDown: 'down',
dirLeft: 'left',
dirRight: 'right',
pluginDisabled: pluginId => `Plugin "${pluginId}" disabled`,
pluginDisabledBody: 'Re-enable it in Settings → Plugins to bring the pane back.',
missingPane: paneId => `missing pane: ${paneId}`,
editTitle: 'Layouts',
editHint: 'Pick a layout, or drag panes between zones. Right-click a zone to split.',
editHint: 'Pick a layout, or drag panes between zones.',
reset: 'Reset',
templates: 'Templates',
custom: 'Custom',

View file

@ -2483,17 +2483,11 @@ export const ja = defineLocale({
closeToRight: '右側を閉じる',
closeAll: 'すべて閉じる',
newSessionTab: '新しいセッションタブ',
split: dir => `${dir}に分割`,
move: dir => `${dir}へ移動`,
dirUp: '上',
dirDown: '下',
dirLeft: '左',
dirRight: '右',
pluginDisabled: pluginId => `プラグイン「${pluginId}」を無効化しました`,
pluginDisabledBody: '設定 → プラグイン で再有効化するとペインが戻ります。',
missingPane: paneId => `ペインが見つかりません: ${paneId}`,
editTitle: 'レイアウト',
editHint: 'レイアウトを選ぶか、ペインをゾーン間へドラッグ。ゾーンを右クリックで分割。',
editHint: 'レイアウトを選ぶか、ペインをゾーン間へドラッグ。',
reset: 'リセット',
templates: 'テンプレート',
custom: 'カスタム',

View file

@ -2257,12 +2257,6 @@ export interface Translations {
closeToRight: string
closeAll: string
newSessionTab: string
split: (dir: string) => string
move: (dir: string) => string
dirUp: string
dirDown: string
dirLeft: string
dirRight: string
pluginDisabled: (pluginId: string) => string
pluginDisabledBody: string
missingPane: (paneId: string) => string

View file

@ -2403,17 +2403,11 @@ export const zhHant = defineLocale({
closeToRight: '關閉右側',
closeAll: '全部關閉',
newSessionTab: '新增工作階段分頁',
split: dir => `${dir}分割`,
move: dir => `${dir}移動`,
dirUp: '上',
dirDown: '下',
dirLeft: '左',
dirRight: '右',
pluginDisabled: pluginId => `外掛「${pluginId}」已停用`,
pluginDisabledBody: '在 設定 → 外掛 中重新啟用即可恢復面板。',
missingPane: paneId => `缺少面板:${paneId}`,
editTitle: '版面配置',
editHint: '選擇一個版面配置,或在區域之間拖曳面板。右鍵點擊區域可分割。',
editHint: '選擇一個版面配置,或在區域之間拖曳面板。',
reset: '重設',
templates: '範本',
custom: '自訂',

View file

@ -2835,17 +2835,11 @@ export const zh: Translations = {
closeToRight: '关闭右侧',
closeAll: '全部关闭',
newSessionTab: '新建会话标签',
split: dir => `${dir}拆分`,
move: dir => `${dir}移动`,
dirUp: '上',
dirDown: '下',
dirLeft: '左',
dirRight: '右',
pluginDisabled: pluginId => `插件“${pluginId}”已禁用`,
pluginDisabledBody: '在 设置 → 插件 中重新启用即可恢复面板。',
missingPane: paneId => `缺少面板:${paneId}`,
editTitle: '布局',
editHint: '选择一个布局,或在区域之间拖动面板。右键点击区域可拆分。',
editHint: '选择一个布局,或在区域之间拖动面板。',
reset: '重置',
templates: '模板',
custom: '自定义',