diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index dfbb31c501a..9245eccd893 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -48,6 +48,16 @@ const MARQUEE_INTERVAL = 80 const CONTEXT_MENU_WIDTH = 16 const MIDDLE_MOUSE_BUTTON = 1 const RIGHT_MOUSE_BUTTON = 2 +const MOUSE_CLOSE_HOLD_MS = 5_000 + +type MouseCloseHold = { + items: string[] + ids: string[] + widths: number[] + closed: string + target: string + x: number +} type TabContextMenuState = { x: number @@ -101,6 +111,45 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu return opacity === 0 ? color : tint(color, background, opacity) } +function heldSessionTabLayout(hold: MouseCloseHold, tabs: readonly SessionTab[]) { + const ids = tabs.map((tab) => tab.sessionID) + const expected = hold.items.filter((id) => id !== hold.closed) + const unchanged = ids.length === hold.items.length && ids.every((id, index) => id === hold.items[index]) + const removed = ids.length === expected.length && ids.every((id, index) => id === expected[index]) + if (!unchanged && !removed) return undefined + + const visibleIDs = hold.ids.filter((id) => ids.includes(id)) + if (removed && !visibleIDs.includes(hold.target)) { + visibleIDs.push(hold.target) + visibleIDs.sort((a, b) => ids.indexOf(a) - ids.indexOf(b)) + } + const positions = visibleIDs.map((id) => ids.indexOf(id)) + const start = positions[0] + if (start === undefined || positions.some((position, index) => position !== start + index)) return undefined + const visible = visibleIDs.flatMap((id) => tabs.find((tab) => tab.sessionID === id) ?? []) + if (visible.length !== visibleIDs.length) return undefined + + const widths = visibleIDs.map((id) => hold.widths[hold.ids.indexOf(id)] ?? 1) + if (removed) { + const index = visibleIDs.indexOf(hold.target) + if (index === -1) return undefined + const leading = start > 0 ? sessionTabOverflowWidth(start) : 0 + const preceding = widths.slice(0, index).reduce((sum, width) => sum + width, 0) + // The close glyph sits one cell in from the right edge: x = tab start + width - 2. + const width = hold.x - leading - preceding + 2 + if (width < 1) return undefined + widths[index] = width + } + return { + tabs: visible, + widths, + before: start, + after: tabs.length - start - visible.length, + start, + total: widths.reduce((sum, width) => sum + width, 0), + } +} + export function createMarquee(animations: () => boolean) { const [offset, setOffset] = createSignal(0) const [active, setActive] = createSignal() @@ -858,9 +907,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // so the strip never flashes the pre-drag order while the write is in flight. const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() const [contextMenu, setContextMenu] = createSignal() - let strip: { screenX: number; screenY: number } | undefined + const [closeHold, setCloseHold] = createSignal() + let strip: { screenX: number; screenY: number; width: number; height: number } | undefined let didDrag = false let addPressed = false + let closeHoldTimer: ReturnType | undefined + let releasingCloseHold = false + const clearCloseHold = () => { + setCloseHold(undefined) + if (closeHoldTimer) clearTimeout(closeHoldTimer) + closeHoldTimer = undefined + } + const releaseCloseHold = () => { + if (!closeHold()) return + releasingCloseHold = true + clearCloseHold() + } + onCleanup(clearCloseHold) // A captured drag ends with a synthetic up on its drop target; do not turn that into a click. let suppressClick = false const hueStep = () => (mode() === "light" ? 800 : 200) @@ -884,14 +947,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID) if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined) }) - const layout = createMemo((previous: ReturnType | undefined) => - adaptiveSessionTabLayout( - items(), - activeID(), - dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0), - previous?.start, - ), + const heldLayout = createMemo(() => { + const hold = closeHold() + return hold ? heldSessionTabLayout(hold, items()) : undefined + }) + const layout = createMemo( + (previous: ReturnType | undefined) => + heldLayout() ?? + adaptiveSessionTabLayout( + items(), + activeID(), + dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0), + previous?.start, + ), ) + createEffect(() => { + if (closeHold() && !heldLayout()) clearCloseHold() + }) createEffect(() => { const active = marquee.active() if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset() @@ -927,6 +999,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim ) let signature = "" let total = 0 + let terminalWidth = dimensions().width // createComputed runs before render effects, so seeded widths are visible on the first frame // of a membership change instead of flashing the final layout. @@ -935,12 +1008,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim const nextSignature = identity() const changed = Boolean(signature) && signature !== nextSignature const resized = Boolean(total) && total !== layout().total + const terminalResized = terminalWidth !== dimensions().width const previous = signature signature = nextSignature total = layout().total + terminalWidth = dimensions().width + const releasing = releasingCloseHold + releasingCloseHold = false + if (terminalResized && closeHold()) { + clearCloseHold() + return + } + if (closeHold() && heldLayout()) { + const current = untrack(motion.value) + const seeded = changed + ? seedSessionTabMotion(previous.split(":"), layout().tabs.map((tab) => tab.sessionID), current, next) + : current + if (!seeded) return motion.jump(next) + motion.jump({ ...seeded, widths: next.widths }) + return motion.animate(next) + } if (!changed && !resized) return motion.animate(next) // Identity-stable total changes are terminal resizes and still jump. - if (!changed) return motion.jump(next) + if (!changed) return releasing ? motion.animate(next) : motion.jump(next) const seeded = seedSessionTabMotion( previous.split(":"), layout().tabs.map((tab) => tab.sessionID), @@ -959,12 +1049,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim const active = activeIndex() const remainder = layout().total - widths.reduce((sum, width) => sum + width, 0) // Absorb only rounding slack; membership animations leave a real gap while widths grow into place. - if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active]! += remainder + if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active] += remainder return new Map( layout().tabs.map((tab, index) => [ tab.sessionID, { - width: widths[index]!, + width: widths[index], selection: current.selections[index] ?? Number(tab.sessionID === activeID()), activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete), }, @@ -972,6 +1062,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim ) }) + const holdCloseCell = (sessionID: string, x: number) => { + if (!strip) return clearCloseHold() + const current = layout() + const index = current.tabs.findIndex((tab) => tab.sessionID === sessionID) + const all = items() + const itemIndex = all.findIndex((tab) => tab.sessionID === sessionID) + const target = all[itemIndex + 1] ?? all[itemIndex - 1] + if (index === -1 || itemIndex === -1 || !target) return clearCloseHold() + const ids = current.tabs.map((tab) => tab.sessionID) + const values = ids.map((id) => visuals().get(id)) + if (values.some((value) => !value)) return clearCloseHold() + setCloseHold({ + items: all.map((tab) => tab.sessionID), + ids, + widths: values.map((value) => value!.width), + closed: sessionID, + target: target.sessionID, + x: x - strip.screenX, + }) + if (closeHoldTimer) clearTimeout(closeHoldTimer) + closeHoldTimer = setTimeout(releaseCloseHold, MOUSE_CLOSE_HOLD_MS) + } + // Map an absolute pointer column to the items index of the visible slot beneath it. const slotAt = (x: number) => { if (!strip) return undefined @@ -1015,7 +1128,17 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim position="relative" flexDirection="row" zIndex={1} - onMouseOut={marquee.leaveHovered} + onMouseOut={(event) => { + marquee.leaveHovered() + if (!strip) return + if ( + event.x < strip.screenX || + event.x >= strip.screenX + strip.width || + event.y < strip.screenY || + event.y >= strip.screenY + strip.height + ) + releaseCloseHold() + }} onMouseUp={(event) => { if (event.button === RIGHT_MOUSE_BUTTON) return release() @@ -1127,6 +1250,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim onMouseOut={() => marquee.leave(tab.sessionID)} onMouseDown={(event) => { if (event.button === MIDDLE_MOUSE_BUTTON) { + releaseCloseHold() didDrag = false setDragging(undefined) tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID) @@ -1135,6 +1259,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim return } if (event.button === RIGHT_MOUSE_BUTTON) { + releaseCloseHold() didDrag = false setDragging(undefined) setContextMenu({ @@ -1148,6 +1273,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim return } didDrag = false + releaseCloseHold() marquee.enter(tab.sessionID, title(), hoveredTitleWidth()) setDragging(tab.sessionID) }} @@ -1205,6 +1331,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // land here first, and must select the tab instead of closing it invisibly. if (hovered() !== tab.sessionID) return event.stopPropagation() + holdCloseCell(tab.sessionID, event.x) tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID) }} > @@ -1229,6 +1356,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim onMouseOver={() => setAddHovered(true)} onMouseOut={() => setAddHovered(false)} onMouseDown={(event) => { + releaseCloseHold() didDrag = false setDragging(undefined) addPressed = event.button !== RIGHT_MOUSE_BUTTON diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index cc425207cf2..45cece235c2 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -99,3 +99,111 @@ test("middle-click closes a session tab without selecting it", async () => { app.renderer.destroy() } }) + +test("keeps consecutive close controls fixed across overflow window changes", async () => { + const [active, setActive] = createSignal("fifth") + const [items, setItems] = createSignal([ + { sessionID: "first", title: "First" }, + { sessionID: "second", title: "Second" }, + { sessionID: "third", title: "Third" }, + { sessionID: "fourth", title: "Fourth" }, + { sessionID: "fifth", title: "Fifth" }, + ]) + const closed: string[] = [] + const controller = { + tabs: items, + current: active, + select: setActive, + close: (sessionID?: string) => { + if (!sessionID) return + const current = items() + closed.push(sessionID) + setActive("first") + setItems(current.filter((tab) => tab.sessionID !== sessionID)) + }, + move() {}, + status: () => EMPTY_SESSION_TAB_STATUS, + } satisfies SessionTabsController + const app = await testRender( + () => ( + + + + + + + + ), + { width: 46, height: 2 }, + ) + + try { + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("Third")) + await app.mockMouse.moveTo(11, 0) + await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[11] === "✕") + + await app.mockMouse.click(11, 0) + await app.waitForFrame((frame) => items().length === 4 && Array.from(frame.split("\n")[0] ?? "")[11] === "✕") + await app.mockMouse.click(11, 0) + + expect(closed).toEqual(["third", "fourth"]) + } finally { + app.renderer.destroy() + } +}) + +test("reflows held tabs when the pointer leaves the strip", async () => { + const [active, setActive] = createSignal("first") + const [items, setItems] = createSignal([ + { sessionID: "first", title: "First" }, + { sessionID: "second", title: "Second" }, + { sessionID: "third", title: "Third" }, + { sessionID: "fourth", title: "Fourth" }, + ]) + const controller = { + tabs: items, + current: active, + select: setActive, + close: (sessionID?: string) => { + if (!sessionID) return + const current = items() + const index = current.findIndex((tab) => tab.sessionID === sessionID) + setActive((current[index + 1] ?? current[index - 1])?.sessionID) + setItems(current.filter((tab) => tab.sessionID !== sessionID)) + }, + move() {}, + status: () => EMPTY_SESSION_TAB_STATUS, + } satisfies SessionTabsController + const app = await testRender( + () => ( + + + + + + outside + + + + + ), + { width: 60, height: 2 }, + ) + + try { + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("Fourth")) + await app.mockMouse.moveTo(22, 0) + await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[22] === "✕") + await app.mockMouse.click(22, 0) + await app.waitForFrame((frame) => items().length === 3 && Array.from(frame.split("\n")[0] ?? "")[22] === "✕") + + await app.mockMouse.moveTo(0, 1) + await app.mockMouse.moveTo(20, 0) + await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[20] === "✕") + expect(Array.from(app.captureCharFrame().split("\n")[0] ?? "")[22]).not.toBe("✕") + } finally { + app.renderer.destroy() + } +})