fix(agent-manager): align panel terminal tabs with session tabs

Panel terminal tabs reuse the tab bar's chrome: right-click Close/Close
Others, arrow-key navigation, overflow scrolling with edge fades, and
frozen widths while closing. The new-terminal button sits next to the
last tab instead of the panel edge, and the strip loses its top padding,
stray dividers, and off-center plus button.
This commit is contained in:
marius-kilocode
2026-07-30 13:59:07 +02:00
parent db74ede208
commit 1e2a7f17d7
9 changed files with 203 additions and 69 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel.
@@ -242,6 +242,30 @@ describe("Agent Manager terminal state", () => {
})
})
it("keeps only the target side terminal on close others", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" })
// Another context must survive untouched: "others" is per context.
item.state.add("wt-1", { id: "terminal:other", title: "Other", wsUrl: "ws://other", font, placement: "side" })
item.state.setSideActive(LOCAL, "terminal:one")
item.handlers.closeSideOthers("terminal:two")
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:two"])
expect(item.state.sidesForContext("wt-1").map((term) => term.id)).toEqual(["terminal:other"])
// The survivor becomes visible and focused, like selecting its tab.
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
expect(item.state.focusRequest()?.id).toBe("terminal:two")
expect(item.posted).toEqual([
{ type: "agentManager.terminal.close", terminalId: "terminal:one" },
{ type: "agentManager.terminal.close", terminalId: "terminal:three" },
])
dispose()
})
})
it("waits for Run closure confirmation while user terminal closes stay optimistic", () => {
createRoot((dispose) => {
const item = scene()
@@ -2855,6 +2855,7 @@ const AgentManagerContent: Component = () => {
visible={() => sidePanel() === "terminal"}
onSelect={(id) => termHandlers.selectSide(id)}
onClose={(id) => termHandlers.closeSide(id)}
onCloseOthers={(id) => termHandlers.closeSideOthers(id)}
onStart={() => termHandlers.addSide()}
/>
</div>
@@ -4656,15 +4656,17 @@ body.vscode-high-contrast-light {
/* Side terminal tab strip one row of tabs reusing the top bar's
.am-tab chrome, plus the "+" action. Height matches .am-diff-header
(32px: 4px padding + 24px content) so switching inspector modes does
not shift the panel chrome. The strip itself never scrolls; the tab
list does, so a narrow panel never pushes the "+" action out of view
(same split as .am-tab-list-wrap / .am-tab-add-wrap). */
(32px) so switching inspector modes does not shift the panel chrome.
No vertical padding: tabs fill the strip like they fill .am-tab-bar,
which also keeps the "+" optically centered. The strip itself never
scrolls; the tab list does, so a narrow panel never pushes the "+"
action out of view (same split as .am-tab-list-wrap /
.am-tab-add-wrap). */
.am-side-terminal-tabs {
display: flex;
align-items: stretch;
height: 32px;
padding: 4px 4px 0;
padding: 0 4px;
gap: 2px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
@@ -4673,11 +4675,20 @@ body.vscode-high-contrast-light {
z-index: 20;
}
/* Same width model as .am-tab-list: tabs claim an equal share of the
strip up to a maximum, and the list itself only grows as wide as its
tabs, so the "+" action stays glued to the last tab instead of
drifting to the far edge of a wide panel. The cap is smaller than the
top bar's 240px because the panel is narrow. */
.am-side-terminal-tablist {
--am-tab-max-width: 180px;
--am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width));
display: flex;
align-items: stretch;
gap: 2px;
flex: 1;
/* No gap, like .am-tab-list: equal-share tab widths already consume
the full width, so any gap would leave the list a few pixels
scrollable and keep the overflow fade lit for nothing. */
flex: 0 1 calc(var(--tab-count, 1) * var(--am-tab-max-width));
min-width: 0;
height: 100%;
overflow-x: auto;
@@ -4689,20 +4700,20 @@ body.vscode-high-contrast-light {
display: none;
}
/* Each tab shares the available width and shrinks with ellipsis.
touch-action unlocks pointer-based drag reordering (same as
.am-tab-sortable). */
.am-side-terminal-tab {
display: flex;
flex: 0 1 140px;
min-width: 64px;
height: 100%;
touch-action: none;
.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable {
transition: none;
}
/* The left divider marks a terminal among session tabs in the top bar.
Every tab here is a terminal, so it would just be noise. */
.am-side-terminal-tablist .am-tab-terminal {
border-left-color: transparent;
}
.am-side-terminal-add {
display: flex;
align-items: center;
align-self: center;
flex-shrink: 0;
padding: 0 2px;
}
@@ -6,19 +6,24 @@
* and one width.
*
* A context can own several side terminals. The header is a tab strip
* that reuses the top tab bar's `TerminalTabChrome` (same `am-tab*`
* structure, same X close button) plus a `+` action to add terminals.
* Tabs are drag-sortable via the same `@thisbeyond/solid-dnd` stack as
* the top tab bar; the order lives in the terminal state, so it is
* that reuses the top tab bar's whole chrome: `SortableTerminalTab`
* (icon, title, X close, right-click Close / Close Others), the same
* `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling
* with edge fades, the same width freeze while tabs close, and the same
* arrow-key tab navigation, so a terminal behaves identically in
* either surface. Reorder state lives in the terminal state, so it is
* preserved across sidebar context switches for the webview's lifetime.
* The strip stays visible even when empty so the `+` action is always
* reachable.
*
* The `+` action sits directly after the last tab (outside the
* scrolling region, like the tab bar's `am-tab-add-wrap`), so it never
* scrolls away and never drifts to the far edge of a wide panel. The
* strip stays visible even when empty so `+` is always reachable.
*
* Visibility is opacity-based, never unmount: the xterm render loop
* dies when its subtree leaves the paint tree (see `render.tsx`).
*/
import type { Accessor, Component } from "solid-js"
import type { Accessor, Component, JSX } from "solid-js"
import { For, Show, createEffect, createSignal } from "solid-js"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -27,11 +32,17 @@ import { Button } from "@kilocode/kilo-ui/button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../../src/context/language"
import { ConstrainDragYAxis, SortableTabContainer } from "../../src/components/chat/TabDnd"
import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd"
import { useTabScroll } from "../../src/utils/tab-scroll"
import { setTabWidths } from "../../src/utils/tab-widths"
import { createTabFocus } from "../../src/utils/tab-navigation"
import { renderSideTerminalLayer } from "./render"
import { TerminalTabChrome } from "./SortableTerminalTab"
import { SortableTerminalTab } from "./SortableTerminalTab"
import type { TerminalStateControls } from "./state"
/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */
const TABLIST = ".am-side-terminal-tablist"
interface Props {
state: TerminalStateControls
/** Context the panel currently shows (`state.sideKey`). */
@@ -42,6 +53,8 @@ interface Props {
onSelect: (terminalId: string) => void
/** Kill one terminal. */
onClose: (terminalId: string) => void
/** Kill every terminal of this context except the given one. */
onCloseOthers: (terminalId: string) => void
/** Create a new side terminal for this context. */
onStart: () => void
}
@@ -49,13 +62,45 @@ interface Props {
export const SideTerminalPanel: Component<Props> = (props) => {
const { t } = useLanguage()
let panel!: HTMLElement
let strip!: HTMLDivElement
createEffect(() => {
panel.inert = !props.visible()
})
const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>()
const sides = () => props.state.sidesForContext(props.contextKey())
const ids = () => sides().map((term) => term.id)
const active = () => props.state.sideActiveFor(props.contextKey())
const pending = () => props.state.pendingSide(props.contextKey())
const scroll = useTabScroll(ids, active)
// Scoped to `strip` so arrow keys and focus restore never jump to a
// tab in the top bar, which uses the same role="tab" markup.
const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip })
// Only freeze while the pointer is over the strip: the widths must
// survive until the pointer leaves, so the remaining X buttons stay
// put across repeated closes. Releasing on the next frame would undo
// the freeze before it is ever painted (rAF runs before paint).
// "Close others" needs none of this: its context menu is portaled, so
// the pointer is off the strip, and the survivor spans the strip anyway.
const freeze = () => {
if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST)
}
const release = () => setTabWidths(false, document, TABLIST)
const close = (id: string) => {
freeze()
props.onClose(id)
// Restore focus inside the strip only while it still owns a tab.
// Falling through to `focusPrompt` would pull focus into the chat
// composer while the panel is still open on its empty state.
if (ids().length > 0) focus.restore()
}
// Adding a tab shrinks every tab's equal share, so any freeze left
// over from a close in the same hover has to go first. `+` lives
// inside the strip, so no pointerleave happens between the two
// clicks and the surviving tabs would keep their wider pixel widths.
const start = () => {
release()
props.onStart()
}
const onDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id !== "string") return
@@ -63,9 +108,13 @@ export const SideTerminalPanel: Component<Props> = (props) => {
// min-width, so a long OSC title would otherwise overflow it and
// shift the visual center off the cursor (the "drag offset" bug).
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
setTabWidths(true, document, TABLIST)
setDragging({ id, width })
}
const onDragEnd = () => setDragging(undefined)
const onDragEnd = () => {
setDragging(undefined)
release()
}
const onDragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
@@ -79,7 +128,12 @@ export const SideTerminalPanel: Component<Props> = (props) => {
aria-label={t("agentManager.tab.terminal")}
aria-hidden={!props.visible()}
>
<div class="am-side-terminal-tabs">
<div
class="am-side-terminal-tabs"
onPointerLeave={() => {
if (!dragging()) release()
}}
>
<DragDropProvider
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@@ -88,42 +142,56 @@ export const SideTerminalPanel: Component<Props> = (props) => {
>
<DragDropSensors />
<ConstrainDragYAxis />
{/* Scrollable tab list mirrors the top bar's .am-tab-list split
so the "+" action never scrolls away. role="tablist" only
when tabs exist: axe aria-required-children rejects an empty
tablist (and non-tab children like the add button). */}
<div
class="am-side-terminal-tablist"
role={sides().length > 0 ? "tablist" : undefined}
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
>
<SortableProvider ids={ids()}>
<For each={sides()}>
{(term) => (
<SortableTabContainer id={term.id} class="am-side-terminal-tab">
<TerminalTabChrome
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
active={props.state.sideActiveFor(props.contextKey()) === term.id}
role="tab"
selected={props.state.sideActiveFor(props.contextKey()) === term.id}
onSelect={() => props.onSelect(term.id)}
onMiddleClick={(e: MouseEvent) => {
if (e.button !== 1) return
e.preventDefault()
e.stopPropagation()
props.onClose(term.id)
}}
onClose={(e: MouseEvent) => {
e.stopPropagation()
props.onClose(term.id)
}}
/>
</SortableTabContainer>
)}
</For>
</SortableProvider>
{/* Overflow chrome copied from the top tab bar: the list is the
only scrolling element, wrapped by a fade host, so the "+"
action stays pinned next to the last tab. */}
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list-wrap">
{/* role="tablist" only when tabs exist: axe
aria-required-children rejects an empty tablist. */}
<div
class="am-side-terminal-tablist"
ref={(el) => {
strip = el
scroll.setRef(el)
}}
role={sides().length > 0 ? "tablist" : undefined}
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={ids()}>
<For each={sides()}>
{(term) => (
<SortableTerminalTab
id={term.id}
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
active={active() === term.id}
role="tab"
selected={active() === term.id}
tabIndex={active() === term.id ? 0 : -1}
onKeyDown={(event) => focus.key(term.id, event)}
onSelect={() => props.onSelect(term.id)}
onMiddleClick={(e: MouseEvent) => {
if (e.button !== 1) return
e.preventDefault()
e.stopPropagation()
close(term.id)
}}
onClose={(e: MouseEvent) => {
e.stopPropagation()
close(term.id)
}}
onCloseOthers={() => props.onCloseOthers(term.id)}
/>
)}
</For>
</SortableProvider>
</div>
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
{/* Cursor-following clone of the dragged tab (same pattern as
the top tab bar). The overlay is what makes the in-list
@@ -147,7 +215,7 @@ export const SideTerminalPanel: Component<Props> = (props) => {
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.add")}
onClick={props.onStart}
onClick={start}
/>
</Tooltip>
</div>
@@ -4,9 +4,9 @@
* `TerminalTabChrome` is the shared visual tab: console icon, title,
* tooltip/keybinding hints, and the X close button the same
* `am-tab*` structure the session tabs use. `SortableTerminalTab`
* wraps it with drag-and-drop and a right-click context menu for the
* top tab bar; the side terminal panel uses the chrome directly so
* both surfaces render identical terminal tabs.
* wraps it with drag-and-drop and a right-click context menu; both the
* top tab bar and the side terminal panel render that wrapper, so a
* terminal tab behaves identically in either surface.
*/
import { Component, Show, type JSX } from "solid-js"
@@ -715,6 +715,22 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
return true
}
/**
* Kill every side terminal of a context except one, and make the
* survivor the visible tab: the side-strip counterpart of the tab
* bar's "Close Others".
*/
const closeSideOthers = (terminalId: string) => {
const key = deps.state.contextFor(terminalId)
if (!key) return
for (const term of deps.state.sidesForContext(key)) {
if (term.id === terminalId) continue
closeSide(term.id)
}
deps.state.setSideActive(key, terminalId)
deps.state.requestFocus(terminalId)
}
/** Make a side terminal the visible one in its panel and focus it. */
const selectSide = (terminalId: string) => {
const key = deps.state.contextFor(terminalId)
@@ -740,6 +756,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
return {
closeTerminal,
closeSide,
closeSideOthers,
selectSide,
middleClick,
activate,
@@ -955,6 +955,7 @@ export const SideTerminalPanelEmpty: Story = {
visible={() => true}
onSelect={() => undefined}
onClose={() => undefined}
onCloseOthers={() => undefined}
onStart={() => undefined}
/>
</div>
@@ -995,6 +996,7 @@ export const SideTerminalPanelTabs: Story = {
visible={() => true}
onSelect={(id) => state.setSideActive(LOCAL, id)}
onClose={() => undefined}
onCloseOthers={() => undefined}
onStart={() => undefined}
/>
</div>
@@ -1,5 +1,11 @@
export function setTabWidths(frozen: boolean, root: ParentNode = document) {
const list = root.querySelector(".am-tab-list")
/**
* Pin tab widths while a strip mutates, so closing a tab does not reflow
* the remaining ones out from under the cursor. `selector` picks the
* strip: the agent-manager tab bar by default, or the side terminal
* strip, which mirrors the same chrome.
*/
export function setTabWidths(frozen: boolean, root: ParentNode = document, selector = ".am-tab-list") {
const list = root.querySelector(selector)
if (!(list instanceof HTMLElement)) return
list.toggleAttribute("data-tab-widths-frozen", frozen)