mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat(agent-manager): support multiple side-panel terminals (#12633)
* feat(agent-manager): support multiple side-panel terminals Add a terminal tab strip to the side panel header so a context can own several side terminals: click to switch, drag to reorder, X to close a single terminal, + to open another one. The strip reuses the top tab bar's TerminalTabChrome and solid-dnd stack (with a width-pinned DragOverlay so drags track the cursor without offset). Terminal numbers fill gaps left by closed terminals, and tabs pick up live titles from OSC escape codes so the shell or running program names its own tab. Closes #12597 * fix(agent-manager): address side terminal strip review findings Split the strip into a scrollable tab list and a fixed add-button area (mirroring .am-tab-list-wrap / .am-tab-add-wrap) so the + action never scrolls away, and scope role=tablist to the tab list so axe aria-required-children passes on the empty strip. Validate closeSide targets a live side terminal before mutating state, so a stray non-side id can no longer drop a record while leaking its backend PTY. Give SortableTabContainer a class prop and reuse it for side tabs instead of a duplicated sortable wrapper.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab.
|
||||
@@ -305,7 +305,7 @@
|
||||
},
|
||||
"packages/kilo-jetbrains": {
|
||||
"name": "@kilocode/kilo-jetbrains",
|
||||
"version": "7.4.15",
|
||||
"version": "7.4.16",
|
||||
},
|
||||
"packages/kilo-memory": {
|
||||
"name": "@kilocode/kilo-memory",
|
||||
|
||||
@@ -113,6 +113,16 @@ export class TerminalManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Titles of every live terminal in a context — used by the router to
|
||||
* pick the lowest free "Terminal N" ordinal. */
|
||||
titles(worktreeId: string | null): string[] {
|
||||
const out: string[] = []
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.worktreeId === worktreeId) out.push(entry.title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Kill a single terminal. Best-effort — we always drop our bookkeeping.
|
||||
* The SDK's `pty.remove` returns `{ data, error }` without throwing
|
||||
* on 4xx/5xx, so we have to check `error` ourselves; otherwise a
|
||||
|
||||
@@ -55,7 +55,9 @@ function isTerminalMessage(
|
||||
|
||||
export class TerminalRouter {
|
||||
private manager: TerminalManager
|
||||
private readonly ordinals = new Map<string, number>()
|
||||
/** Ordinals reserved by in-flight creates, per context — prevents two
|
||||
* concurrent creates from grabbing the same "Terminal N" title. */
|
||||
private readonly reserved = new Map<string, Set<number>>()
|
||||
private generation = 0
|
||||
|
||||
constructor(private readonly deps: TerminalRoutingDeps) {
|
||||
@@ -102,6 +104,7 @@ export class TerminalRouter {
|
||||
this.generation++
|
||||
const manager = this.manager
|
||||
this.manager = this.createManager()
|
||||
this.reserved.clear()
|
||||
return manager.dispose()
|
||||
}
|
||||
|
||||
@@ -119,7 +122,8 @@ export class TerminalRouter {
|
||||
})
|
||||
return
|
||||
}
|
||||
const title = `Terminal ${this.nextOrdinal(worktreeId)}`
|
||||
const ordinal = this.reserveOrdinal(worktreeId)
|
||||
const title = `Terminal ${ordinal}`
|
||||
try {
|
||||
// Join the shared backend connection instead of racing its synchronous
|
||||
// client accessor when this is the first Kilo action in the window.
|
||||
@@ -144,6 +148,11 @@ export class TerminalRouter {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
this.deps.log(`Terminal create failed: ${message}`)
|
||||
this.deps.post({ type: "agentManager.terminal.error", createId, message })
|
||||
} finally {
|
||||
// Only a current-generation create may release: dispose() already
|
||||
// cleared this create's reservation, and releasing here would
|
||||
// delete a *new* panel's reservation for the same number.
|
||||
if (generation === this.generation) this.releaseOrdinal(worktreeId, ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,15 +170,40 @@ export class TerminalRouter {
|
||||
return this.deps.getWorktreePath(worktreeId)
|
||||
}
|
||||
|
||||
/** Per-context counter so default titles are "Terminal 1", "Terminal 2"…
|
||||
* Not persisted; a webview reload resets counts. */
|
||||
private nextOrdinal(worktreeId: string | null): number {
|
||||
/**
|
||||
* Pick the lowest "Terminal N" ordinal not used by a live terminal or
|
||||
* an in-flight create in this context, and reserve it until the
|
||||
* create settles. Gap-filling keeps numbering consistent: closing
|
||||
* "Terminal 1" of three frees 1 for the next terminal, instead of
|
||||
* drifting to ever-higher numbers. Not persisted; a webview reload
|
||||
* resets the live set.
|
||||
*/
|
||||
private reserveOrdinal(worktreeId: string | null): number {
|
||||
const key = worktreeId ?? "__local__"
|
||||
const next = (this.ordinals.get(key) ?? 0) + 1
|
||||
this.ordinals.set(key, next)
|
||||
const used = new Set<number>()
|
||||
for (const title of this.manager.titles(worktreeId)) {
|
||||
const match = /^Terminal (\d+)$/.exec(title)
|
||||
if (match) used.add(Number(match[1]))
|
||||
}
|
||||
const pending = this.reserved.get(key)
|
||||
if (pending) for (const n of pending) used.add(n)
|
||||
let next = 1
|
||||
while (used.has(next)) next++
|
||||
const set = pending ?? new Set<number>()
|
||||
set.add(next)
|
||||
this.reserved.set(key, set)
|
||||
return next
|
||||
}
|
||||
|
||||
/** Return an in-flight create's reservation. */
|
||||
private releaseOrdinal(worktreeId: string | null, ordinal: number) {
|
||||
const key = worktreeId ?? "__local__"
|
||||
const set = this.reserved.get(key)
|
||||
if (!set) return
|
||||
set.delete(ordinal)
|
||||
if (set.size === 0) this.reserved.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL for a given PTY.
|
||||
*
|
||||
|
||||
@@ -111,6 +111,134 @@ describe("Agent Manager terminal routing", () => {
|
||||
expect(removed).toContain("pty-new")
|
||||
})
|
||||
|
||||
it("fills numbering gaps left by closed terminals", async () => {
|
||||
const messages: AgentManagerOutMessage[] = []
|
||||
const titles: string[] = []
|
||||
let seq = 0
|
||||
const client = {
|
||||
pty: {
|
||||
create: async ({ title }: { title: string }) => {
|
||||
titles.push(title)
|
||||
seq++
|
||||
return { data: { id: `pty-${seq}`, title } }
|
||||
},
|
||||
remove: async () => ({ data: true }),
|
||||
update: async () => ({ data: true }),
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const router = new TerminalRouter({
|
||||
getClient: () => client,
|
||||
getClientAsync: async () => client,
|
||||
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
|
||||
getRoot: () => "/workspace",
|
||||
getWorktreePath: () => undefined,
|
||||
log: () => undefined,
|
||||
post: (message) => messages.push(message),
|
||||
getTerminalFont: () => font,
|
||||
})
|
||||
const create = (createId: string) =>
|
||||
router.handle({ type: "agentManager.terminal.create", createId, placement: "side", worktreeId: null })
|
||||
|
||||
create("one")
|
||||
await wait()
|
||||
create("two")
|
||||
await wait()
|
||||
expect(titles).toEqual(["Terminal 1", "Terminal 2"])
|
||||
|
||||
// Close "Terminal 1"; the next create reuses the freed number.
|
||||
const first = messages.find((m) => m.type === "agentManager.terminal.created" && m.createId === "one")
|
||||
if (first?.type !== "agentManager.terminal.created") throw new Error("missing created message")
|
||||
router.handle({ type: "agentManager.terminal.close", terminalId: first.terminalId })
|
||||
await wait()
|
||||
|
||||
create("three")
|
||||
await wait()
|
||||
expect(titles).toEqual(["Terminal 1", "Terminal 2", "Terminal 1"])
|
||||
await router.dispose()
|
||||
})
|
||||
|
||||
it("hands out distinct numbers to concurrent creates", async () => {
|
||||
const messages: AgentManagerOutMessage[] = []
|
||||
const titles: string[] = []
|
||||
const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = []
|
||||
const client = {
|
||||
pty: {
|
||||
create: ({ title }: { title: string }) =>
|
||||
new Promise<{ data: { id: string; title: string } }>((resolve) => {
|
||||
titles.push(title)
|
||||
resolvers.push(resolve)
|
||||
}),
|
||||
remove: async () => ({ data: true }),
|
||||
update: async () => ({ data: true }),
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const router = new TerminalRouter({
|
||||
getClient: () => client,
|
||||
getClientAsync: async () => client,
|
||||
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
|
||||
getRoot: () => "/workspace",
|
||||
getWorktreePath: () => undefined,
|
||||
log: () => undefined,
|
||||
post: (message) => messages.push(message),
|
||||
getTerminalFont: () => font,
|
||||
})
|
||||
|
||||
// Two creates before either settles must not share an ordinal. The
|
||||
// backend-connection await defers the PTY creates to a microtask.
|
||||
router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null })
|
||||
router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null })
|
||||
await wait()
|
||||
expect(titles).toEqual(["Terminal 1", "Terminal 2"])
|
||||
resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } })
|
||||
resolvers[1]?.({ data: { id: "pty-b", title: titles[1]! } })
|
||||
await wait()
|
||||
|
||||
// A failed create releases its reservation for the next attempt.
|
||||
await router.dispose()
|
||||
})
|
||||
|
||||
it("does not let a stale create release a new generation's reservation", async () => {
|
||||
const titles: string[] = []
|
||||
const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = []
|
||||
const client = {
|
||||
pty: {
|
||||
create: ({ title }: { title: string }) =>
|
||||
new Promise<{ data: { id: string; title: string } }>((resolve) => {
|
||||
titles.push(title)
|
||||
resolvers.push(resolve)
|
||||
}),
|
||||
remove: async () => ({ data: true }),
|
||||
update: async () => ({ data: true }),
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const router = new TerminalRouter({
|
||||
getClient: () => client,
|
||||
getClientAsync: async () => client,
|
||||
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
|
||||
getRoot: () => "/workspace",
|
||||
getWorktreePath: () => undefined,
|
||||
log: () => undefined,
|
||||
post: () => undefined,
|
||||
getTerminalFont: () => font,
|
||||
})
|
||||
|
||||
// Create A starts before the panel is recreated; its reservation dies
|
||||
// with dispose(). Create B of the new generation reserves the same
|
||||
// free number. When A's late completion settles, its release must not
|
||||
// wipe B's reservation — otherwise create C would duplicate B's title.
|
||||
router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null })
|
||||
await router.dispose()
|
||||
router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null })
|
||||
await wait()
|
||||
expect(titles).toEqual(["Terminal 1", "Terminal 1"])
|
||||
resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } })
|
||||
await wait()
|
||||
router.handle({ type: "agentManager.terminal.create", createId: "c", placement: "side", worktreeId: null })
|
||||
await wait()
|
||||
expect(titles).toEqual(["Terminal 1", "Terminal 1", "Terminal 2"])
|
||||
await router.dispose()
|
||||
})
|
||||
|
||||
it("awaits the shared backend connection before creating a terminal", async () => {
|
||||
let connected = false
|
||||
const client = {
|
||||
@@ -120,6 +248,7 @@ describe("Agent Manager terminal routing", () => {
|
||||
update: async () => ({ data: true }),
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const messages: AgentManagerOutMessage[] = []
|
||||
const router = new TerminalRouter({
|
||||
getClient: () => {
|
||||
if (!connected) throw new Error("Not connected")
|
||||
@@ -138,7 +267,6 @@ describe("Agent Manager terminal routing", () => {
|
||||
getTerminalFont: () => font,
|
||||
})
|
||||
|
||||
const messages: AgentManagerOutMessage[] = []
|
||||
router.handle({
|
||||
type: "agentManager.terminal.create",
|
||||
createId: "real",
|
||||
|
||||
@@ -10,12 +10,12 @@ function scene(
|
||||
destination?: "vscode" | "agentManager"
|
||||
saved?: "vscode" | "agentManager"
|
||||
visible?: boolean
|
||||
focused?: boolean
|
||||
focusedId?: string
|
||||
} = {},
|
||||
) {
|
||||
const calls = {
|
||||
requestSide: 0,
|
||||
closeSide: 0,
|
||||
closed: [] as string[],
|
||||
hide: 0,
|
||||
refocus: 0,
|
||||
openVscode: 0,
|
||||
@@ -24,21 +24,21 @@ function scene(
|
||||
tracked: [] as string[],
|
||||
}
|
||||
let visible = opts.visible ?? false
|
||||
let focused = opts.focused ?? false
|
||||
let focusedId = opts.focusedId as string | undefined
|
||||
const ctl = createSideTerminal({
|
||||
handlers: {
|
||||
requestSide: () => {
|
||||
calls.requestSide++
|
||||
visible = true
|
||||
},
|
||||
closeSide: () => {
|
||||
calls.closeSide++
|
||||
visible = false
|
||||
closeSide: (terminalId) => {
|
||||
calls.closed.push(terminalId)
|
||||
focusedId = undefined
|
||||
return true
|
||||
},
|
||||
},
|
||||
visible: () => visible,
|
||||
focused: () => focused,
|
||||
focusedId: () => focusedId,
|
||||
hide: () => {
|
||||
calls.hide++
|
||||
visible = false
|
||||
@@ -56,12 +56,12 @@ function scene(
|
||||
|
||||
describe("Agent Manager side terminal controller", () => {
|
||||
it("toggles the panel and hands focus to the chat only when the terminal had it", () => {
|
||||
const focused = scene({ destination: "agentManager", visible: true, focused: true })
|
||||
const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" })
|
||||
focused.ctl.toggle()
|
||||
expect(focused.calls.hide).toBe(1)
|
||||
expect(focused.calls.refocus).toBe(1)
|
||||
|
||||
const elsewhere = scene({ destination: "agentManager", visible: true, focused: false })
|
||||
const elsewhere = scene({ destination: "agentManager", visible: true })
|
||||
elsewhere.ctl.toggle()
|
||||
expect(elsewhere.calls.hide).toBe(1)
|
||||
expect(elsewhere.calls.refocus).toBe(0)
|
||||
@@ -72,15 +72,18 @@ describe("Agent Manager side terminal controller", () => {
|
||||
expect(hidden.calls.hide).toBe(0)
|
||||
})
|
||||
|
||||
it("refocuses the chat after killing a focused terminal, not otherwise", () => {
|
||||
const focused = scene({ focused: true })
|
||||
it("kills the focused terminal and refocuses the chat", () => {
|
||||
const focused = scene({ focusedId: "terminal:two" })
|
||||
expect(focused.ctl.close()).toBe(true)
|
||||
expect(focused.calls.closeSide).toBe(1)
|
||||
expect(focused.calls.closed).toEqual(["terminal:two"])
|
||||
expect(focused.calls.refocus).toBe(1)
|
||||
})
|
||||
|
||||
const elsewhere = scene({ focused: false })
|
||||
expect(elsewhere.ctl.close()).toBe(true)
|
||||
expect(elsewhere.calls.refocus).toBe(0)
|
||||
it("does nothing on close without a focused terminal", () => {
|
||||
const item = scene()
|
||||
expect(item.ctl.close()).toBe(false)
|
||||
expect(item.calls.closed).toEqual([])
|
||||
expect(item.calls.refocus).toBe(0)
|
||||
})
|
||||
|
||||
it("routes the primary action by destination", () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ function scene(initial: string | null = LOCAL) {
|
||||
const [selection, setSelection] = createSignal<string | null>(initial)
|
||||
const state = createTerminalState(selection)
|
||||
const posted: Array<Record<string, unknown>> = []
|
||||
const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], hidden: 0 }
|
||||
const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], errors: 0 }
|
||||
const tabs = () => state.current().map((term) => term.id)
|
||||
const handlers = createTerminalHandlers({
|
||||
state,
|
||||
@@ -27,7 +27,6 @@ function scene(initial: string | null = LOCAL) {
|
||||
findTab: () => undefined,
|
||||
postMessage: (message) => posted.push(message as Record<string, unknown>),
|
||||
onShowSide: (key) => events.shown.push(key),
|
||||
onHideSide: () => events.hidden++,
|
||||
getSelection: selection,
|
||||
LOCAL,
|
||||
REVIEW_TAB_ID: "review",
|
||||
@@ -40,12 +39,25 @@ function scene(initial: string | null = LOCAL) {
|
||||
events.selected.push(value)
|
||||
setSelection(value)
|
||||
},
|
||||
showError: () => undefined,
|
||||
showError: () => events.errors++,
|
||||
postMessage: (message) => posted.push(message as Record<string, unknown>),
|
||||
})
|
||||
return { state, selection, setSelection, posted, events, handlers, dispatch }
|
||||
}
|
||||
|
||||
function createdSide(createId: string, terminalId: string, title = "Terminal 1") {
|
||||
return {
|
||||
type: "agentManager.terminal.created",
|
||||
createId,
|
||||
placement: "side",
|
||||
worktreeId: null,
|
||||
terminalId,
|
||||
title,
|
||||
wsUrl: `ws://${terminalId}`,
|
||||
font,
|
||||
} satisfies ExtensionMessage
|
||||
}
|
||||
|
||||
describe("Agent Manager terminal state", () => {
|
||||
it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => {
|
||||
createRoot((dispose) => {
|
||||
@@ -64,21 +76,22 @@ describe("Agent Manager terminal state", () => {
|
||||
font,
|
||||
placement: "side",
|
||||
})
|
||||
item.state.setSideActive(LOCAL, "terminal:side")
|
||||
|
||||
expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"])
|
||||
expect(item.state.all().map((term) => term.id)).toEqual(["terminal:tab"])
|
||||
expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"])
|
||||
expect(item.state.side()?.id).toBe("terminal:side")
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
|
||||
|
||||
item.setSelection(null)
|
||||
expect(item.state.current()).toEqual([])
|
||||
expect(item.state.sideKey()).toBe(LOCAL)
|
||||
expect(item.state.side()?.id).toBe("terminal:side")
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("deduplicates side creation and reuses the terminal without tab side effects", () => {
|
||||
it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.handlers.requestSide()
|
||||
@@ -88,18 +101,8 @@ describe("Agent Manager terminal state", () => {
|
||||
const request = item.posted[0]!
|
||||
expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null })
|
||||
const createId = String(request.createId)
|
||||
const created = {
|
||||
type: "agentManager.terminal.created",
|
||||
createId,
|
||||
placement: "side",
|
||||
worktreeId: null,
|
||||
terminalId: "terminal:side",
|
||||
title: "Terminal 1",
|
||||
wsUrl: "ws://side",
|
||||
font,
|
||||
} satisfies ExtensionMessage
|
||||
expect(item.dispatch(created)).toBe(true)
|
||||
expect(item.state.side()?.id).toBe("terminal:side")
|
||||
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
|
||||
expect(item.events.activated).toEqual([])
|
||||
expect(item.events.selected).toEqual([])
|
||||
expect(item.events.saved).toBe(0)
|
||||
@@ -111,6 +114,74 @@ describe("Agent Manager terminal state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("supports several side terminals per context with newest active", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.handlers.addSide()
|
||||
item.handlers.addSide()
|
||||
expect(item.posted).toHaveLength(2)
|
||||
const first = String(item.posted[0]!.createId)
|
||||
const second = String(item.posted[1]!.createId)
|
||||
|
||||
item.dispatch(createdSide(first, "terminal:one", "Terminal 1"))
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"])
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
|
||||
|
||||
item.dispatch(createdSide(second, "terminal:two", "Terminal 2"))
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"])
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("switches the active side terminal on select", () => {
|
||||
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.setSideActive(LOCAL, "terminal:two")
|
||||
|
||||
item.handlers.selectSide("terminal:one")
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
|
||||
expect(item.state.focusRequest()?.id).toBe("terminal:one")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("moves activation to the last remaining side terminal on close", () => {
|
||||
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.setSideActive(LOCAL, "terminal:two")
|
||||
|
||||
expect(item.handlers.closeSide("terminal:two")).toBe(true)
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
|
||||
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }])
|
||||
|
||||
expect(item.handlers.closeSide("terminal:one")).toBe(true)
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBeUndefined()
|
||||
expect(item.state.sidesForContext(LOCAL)).toEqual([])
|
||||
|
||||
// Closing an unknown or non-side id is a no-op.
|
||||
expect(item.handlers.closeSide("terminal:gone")).toBe(false)
|
||||
expect(item.posted).toHaveLength(2)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("closes a stale side answer whose create request is unknown", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
// A created message for a createId the webview never sent (e.g. it
|
||||
// reloaded while the PTY was starting) must not leak the PTY.
|
||||
item.dispatch(createdSide("stale-id", "terminal:stale"))
|
||||
expect(item.state.sidesForContext(LOCAL)).toEqual([])
|
||||
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:stale" }])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("creates explicit terminal tabs independently of the side destination", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene("wt-1")
|
||||
@@ -124,44 +195,91 @@ describe("Agent Manager terminal state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("cancels a side terminal that is still starting", () => {
|
||||
it("routes side creates of a worktree context to that worktree", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.handlers.requestSide()
|
||||
const request = item.posted[0]!
|
||||
expect(item.handlers.closeSide()).toBe(true)
|
||||
expect(item.state.pendingSide(LOCAL)).toBeUndefined()
|
||||
|
||||
item.dispatch({
|
||||
type: "agentManager.terminal.created",
|
||||
createId: String(request.createId),
|
||||
const item = scene("wt-1")
|
||||
item.handlers.addSide()
|
||||
expect(item.posted[0]).toMatchObject({
|
||||
type: "agentManager.terminal.create",
|
||||
placement: "side",
|
||||
worktreeId: null,
|
||||
terminalId: "terminal:late",
|
||||
title: "Terminal 1",
|
||||
wsUrl: "ws://late",
|
||||
font,
|
||||
worktreeId: "wt-1",
|
||||
})
|
||||
expect(item.state.side()).toBeUndefined()
|
||||
expect(item.posted.at(-1)).toEqual({ type: "agentManager.terminal.close", terminalId: "terminal:late" })
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("closes a side terminal without changing the active chat tab", () => {
|
||||
it("tracks OSC titles per terminal without touching the terminal records", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, {
|
||||
id: "terminal:side",
|
||||
title: "Terminal 1",
|
||||
wsUrl: "ws://side",
|
||||
font,
|
||||
placement: "side",
|
||||
})
|
||||
expect(item.handlers.closeSide()).toBe(true)
|
||||
expect(item.state.side()).toBeUndefined()
|
||||
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:side" }])
|
||||
expect(item.events.hidden).toBe(1)
|
||||
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
|
||||
const before = item.state.sidesForContext(LOCAL)[0]!
|
||||
|
||||
item.state.setTitle("terminal:one", "npm run dev")
|
||||
expect(item.state.title("terminal:one")).toBe("npm run dev")
|
||||
// Reference stability: the stored record is untouched so <For> does
|
||||
// not remount the xterm instance on a title change.
|
||||
expect(item.state.sidesForContext(LOCAL)[0]).toBe(before)
|
||||
|
||||
// Empty titles are ignored; removal drops the override.
|
||||
item.state.setTitle("terminal:one", " ")
|
||||
expect(item.state.title("terminal:one")).toBe("npm run dev")
|
||||
item.state.remove("terminal:one")
|
||||
expect(item.state.title("terminal:one")).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("reorders side terminals within their context via drag", () => {
|
||||
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" })
|
||||
item.state.add(null, { id: "terminal:tab", title: "Terminal 4", wsUrl: "ws://tab", font, placement: "tab" })
|
||||
|
||||
// Drag the first side terminal onto the third position.
|
||||
expect(item.state.reorderSideDrag(LOCAL, "terminal:one", "terminal:three")).toBe(true)
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([
|
||||
"terminal:two",
|
||||
"terminal:three",
|
||||
"terminal:one",
|
||||
])
|
||||
// Tab terminals are untouched.
|
||||
expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"])
|
||||
|
||||
// The order survives switching to another context and back.
|
||||
item.setSelection("wt-1")
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([
|
||||
"terminal:two",
|
||||
"terminal:three",
|
||||
"terminal:one",
|
||||
])
|
||||
item.setSelection(LOCAL)
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([
|
||||
"terminal:two",
|
||||
"terminal:three",
|
||||
"terminal:one",
|
||||
])
|
||||
|
||||
// Unknown ids, tab-placement ids, and foreign contexts are rejected.
|
||||
expect(item.state.reorderSideDrag(LOCAL, "terminal:gone", "terminal:two")).toBe(false)
|
||||
expect(item.state.reorderSideDrag(LOCAL, "terminal:tab", "terminal:two")).toBe(false)
|
||||
expect(item.state.reorderSideDrag("wt-1", "terminal:two", "terminal:three")).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("reports the focused side terminal only for the current context", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:side", title: "Terminal 1", wsUrl: "ws://side", font, placement: "side" })
|
||||
item.state.add(null, { id: "terminal:tab", title: "Terminal 2", wsUrl: "ws://tab", font, placement: "tab" })
|
||||
|
||||
expect(item.state.sideFocusedId()).toBeUndefined()
|
||||
item.state.setFocusedId("terminal:tab")
|
||||
expect(item.state.sideFocusedId()).toBeUndefined()
|
||||
item.state.setFocusedId("terminal:side")
|
||||
expect(item.state.sideFocusedId()).toBe("terminal:side")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,9 +75,12 @@ async function settle(page: Page) {
|
||||
// Spinner animation captures at an indeterminate frame, causing flaky diffs.
|
||||
// Permission dock config-preloaded has non-deterministic toggle rendering.
|
||||
// Sandboxing rows can settle at different scroll heights after settings context updates.
|
||||
// Side terminal tabs mount live xterm instances whose websocket error text
|
||||
// lands at indeterminate times.
|
||||
const SKIP = new Set<string>([
|
||||
"agentmanager--worktree-item-busy",
|
||||
"agentmanager--full-screen-diff-agent-edit-scroll",
|
||||
"agentmanager--side-terminal-panel-tabs",
|
||||
"composite-webview--permission-dock-config-preloaded",
|
||||
"settings--sandboxing-allowlist",
|
||||
"settings--sandboxing-panel",
|
||||
|
||||
@@ -75,9 +75,12 @@ async function settle(page: Page) {
|
||||
// Spinner animation captures at an indeterminate frame, causing flaky diffs.
|
||||
// Permission dock config-preloaded has non-deterministic toggle rendering.
|
||||
// Sandboxing rows can settle at different scroll heights after settings context updates.
|
||||
// Side terminal tabs mount live xterm instances whose websocket error text
|
||||
// lands at indeterminate times.
|
||||
const SKIP = new Set<string>([
|
||||
"agentmanager--worktree-item-busy",
|
||||
"agentmanager--full-screen-diff-agent-edit-scroll",
|
||||
"agentmanager--side-terminal-panel-tabs",
|
||||
"composite-webview--permission-dock-config-preloaded",
|
||||
"settings--sandboxing-allowlist",
|
||||
"settings--sandboxing-panel",
|
||||
|
||||
@@ -1833,9 +1833,6 @@ const AgentManagerContent: Component = () => {
|
||||
postMessage: (msg) => vscode.postMessage(msg as never),
|
||||
onRemove: freezeTabs,
|
||||
onShowSide: showSideTerminal,
|
||||
onHideSide: () => {
|
||||
if (sidePanel() === "terminal") setSidePanel(null)
|
||||
},
|
||||
getSelection: selection,
|
||||
LOCAL,
|
||||
REVIEW_TAB_ID,
|
||||
@@ -1844,7 +1841,7 @@ const AgentManagerContent: Component = () => {
|
||||
const sideCtl = createSideTerminal({
|
||||
handlers: termHandlers,
|
||||
visible: () => sidePanel() === "terminal",
|
||||
focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id,
|
||||
focusedId: () => terms.sideFocusedId(),
|
||||
hide: () => setSidePanel(null),
|
||||
refocus: () => window.dispatchEvent(new Event("focusPrompt")),
|
||||
postMessage: (msg) => vscode.postMessage(msg as never),
|
||||
@@ -1933,8 +1930,8 @@ const AgentManagerContent: Component = () => {
|
||||
if (!id) return undefined
|
||||
if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") }
|
||||
if (isTerminalTabId(id)) {
|
||||
const term = terms.lookup().get(id)
|
||||
return term ? { id, title: term.title } : undefined
|
||||
const title = terms.title(id)
|
||||
return title ? { id, title } : undefined
|
||||
}
|
||||
return activeTabs().find((s) => s.id === id)
|
||||
})
|
||||
@@ -1962,8 +1959,8 @@ const AgentManagerContent: Component = () => {
|
||||
const closeActiveTab = () => {
|
||||
// A focused side terminal owns Cmd+W while its panel is visible —
|
||||
// closing a chat tab out from under the user's cursor would be
|
||||
// surprising.
|
||||
if (sidePanel() === "terminal" && terms.focusedId() && terms.focusedId() === terms.side()?.id) {
|
||||
// surprising. Only that terminal dies; the panel keeps the rest.
|
||||
if (sidePanel() === "terminal" && terms.sideFocusedId()) {
|
||||
if (sideCtl.close()) return
|
||||
}
|
||||
if (termHandlers.closeActive()) {
|
||||
@@ -2835,8 +2832,9 @@ const AgentManagerContent: Component = () => {
|
||||
state={terms}
|
||||
contextKey={terms.sideKey}
|
||||
visible={() => sidePanel() === "terminal"}
|
||||
onClose={() => sideCtl.close()}
|
||||
onStart={() => termHandlers.requestSide()}
|
||||
onSelect={(id) => termHandlers.selectSide(id)}
|
||||
onClose={(id) => termHandlers.closeSide(id)}
|
||||
onStart={() => termHandlers.addSide()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4618,6 +4618,59 @@ body.vscode-high-contrast-light {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
.am-side-terminal-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 32px;
|
||||
padding: 4px 4px 0;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.am-side-terminal-tablist {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.am-side-terminal-tablist::-webkit-scrollbar {
|
||||
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-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Hidden-but-alive side panel host: taken out of the flow so the chat
|
||||
reclaims the width, but kept painted so hidden side terminals keep
|
||||
streaming. Anchored to .am-detail-stack (position: relative). */
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية",
|
||||
"agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء",
|
||||
"agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية",
|
||||
"agentManager.terminal.kill": "إنهاء المحطة الطرفية",
|
||||
"agentManager.terminal.add": "محطة طرفية جديدة",
|
||||
"agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد",
|
||||
"agentManager.terminal.start": "بدء المحطة الطرفية",
|
||||
"agentManager.terminal.destination": "اختر ما الذي يفتحه زر المحطة الطرفية",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nova aba de terminal",
|
||||
"agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar",
|
||||
"agentManager.terminal.connectionError": "erro de conexão do terminal",
|
||||
"agentManager.terminal.kill": "Encerrar terminal",
|
||||
"agentManager.terminal.add": "Novo terminal",
|
||||
"agentManager.terminal.empty": "Ainda não há terminal aqui",
|
||||
"agentManager.terminal.start": "Iniciar terminal",
|
||||
"agentManager.terminal.destination": "Escolha o que o botão do terminal abre",
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nova kartica terminala",
|
||||
"agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili",
|
||||
"agentManager.terminal.connectionError": "greška u vezi terminala",
|
||||
"agentManager.terminal.kill": "Prekini terminal",
|
||||
"agentManager.terminal.add": "Novi terminal",
|
||||
"agentManager.terminal.empty": "Ovdje još nema terminala",
|
||||
"agentManager.terminal.start": "Pokreni terminal",
|
||||
"agentManager.terminal.destination": "Odaberite šta otvara dugme terminala",
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Ny terminalfane",
|
||||
"agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne",
|
||||
"agentManager.terminal.connectionError": "forbindelsesfejl til terminal",
|
||||
"agentManager.terminal.kill": "Afslut terminal",
|
||||
"agentManager.terminal.add": "Ny terminal",
|
||||
"agentManager.terminal.empty": "Ingen terminal her endnu",
|
||||
"agentManager.terminal.start": "Start terminal",
|
||||
"agentManager.terminal.destination": "Vælg, hvad terminalknappen åbner",
|
||||
|
||||
@@ -59,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Neuer Terminal-Tab",
|
||||
"agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen",
|
||||
"agentManager.terminal.connectionError": "Verbindungsfehler im Terminal",
|
||||
"agentManager.terminal.kill": "Terminal beenden",
|
||||
"agentManager.terminal.add": "Neues Terminal",
|
||||
"agentManager.terminal.empty": "Hier ist noch kein Terminal",
|
||||
"agentManager.terminal.start": "Terminal starten",
|
||||
"agentManager.terminal.destination": "Auswählen, was die Terminal-Schaltfläche öffnet",
|
||||
|
||||
@@ -60,9 +60,9 @@ export const dict = {
|
||||
"agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES",
|
||||
|
||||
"agentManager.terminal.new": "New Terminal Tab",
|
||||
"agentManager.terminal.add": "New terminal",
|
||||
"agentManager.terminal.ended": "terminal ended — close tab to dismiss",
|
||||
"agentManager.terminal.connectionError": "terminal connection error",
|
||||
"agentManager.terminal.kill": "Kill terminal",
|
||||
"agentManager.terminal.empty": "No terminal here yet",
|
||||
"agentManager.terminal.start": "Start terminal",
|
||||
"agentManager.terminal.destination": "Choose what the terminal button opens",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nueva pestaña de terminal",
|
||||
"agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar",
|
||||
"agentManager.terminal.connectionError": "error de conexión del terminal",
|
||||
"agentManager.terminal.kill": "Terminar terminal",
|
||||
"agentManager.terminal.add": "Nuevo terminal",
|
||||
"agentManager.terminal.empty": "Aún no hay ningún terminal aquí",
|
||||
"agentManager.terminal.start": "Iniciar terminal",
|
||||
"agentManager.terminal.destination": "Elegir qué abre el botón del terminal",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nouvel onglet de terminal",
|
||||
"agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer",
|
||||
"agentManager.terminal.connectionError": "erreur de connexion du terminal",
|
||||
"agentManager.terminal.kill": "Tuer le terminal",
|
||||
"agentManager.terminal.add": "Nouveau terminal",
|
||||
"agentManager.terminal.empty": "Aucun terminal ici pour l'instant",
|
||||
"agentManager.terminal.start": "Démarrer le terminal",
|
||||
"agentManager.terminal.destination": "Choisir ce que le bouton Terminal ouvre",
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nuova scheda terminale",
|
||||
"agentManager.terminal.ended": "terminale terminato - chiudi la scheda per nasconderlo",
|
||||
"agentManager.terminal.connectionError": "errore di connessione del terminale",
|
||||
"agentManager.terminal.kill": "Termina terminale",
|
||||
"agentManager.terminal.add": "Nuovo terminale",
|
||||
"agentManager.terminal.empty": "Qui non c'è ancora un terminale",
|
||||
"agentManager.terminal.start": "Avvia terminale",
|
||||
"agentManager.terminal.destination": "Scegli cosa apre il pulsante del terminale",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "新しいターミナルタブ",
|
||||
"agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄",
|
||||
"agentManager.terminal.connectionError": "ターミナル接続エラー",
|
||||
"agentManager.terminal.kill": "ターミナルを終了",
|
||||
"agentManager.terminal.add": "新しいターミナル",
|
||||
"agentManager.terminal.empty": "ここにはまだターミナルがありません",
|
||||
"agentManager.terminal.start": "ターミナルを開始",
|
||||
"agentManager.terminal.destination": "ターミナルボタンで開く場所を選択",
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "새 터미널 탭",
|
||||
"agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제",
|
||||
"agentManager.terminal.connectionError": "터미널 연결 오류",
|
||||
"agentManager.terminal.kill": "터미널 종료",
|
||||
"agentManager.terminal.add": "새 터미널",
|
||||
"agentManager.terminal.empty": "아직 여기에 터미널이 없습니다",
|
||||
"agentManager.terminal.start": "터미널 시작",
|
||||
"agentManager.terminal.destination": "터미널 버튼으로 열 위치 선택",
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nieuw terminaltabblad",
|
||||
"agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren",
|
||||
"agentManager.terminal.connectionError": "terminalverbindingsfout",
|
||||
"agentManager.terminal.kill": "Terminal beëindigen",
|
||||
"agentManager.terminal.add": "Nieuwe terminal",
|
||||
"agentManager.terminal.empty": "Hier is nog geen terminal",
|
||||
"agentManager.terminal.start": "Terminal starten",
|
||||
"agentManager.terminal.destination": "Kies wat de terminalknop opent",
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Ny terminalfane",
|
||||
"agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise",
|
||||
"agentManager.terminal.connectionError": "tilkoblingsfeil for terminal",
|
||||
"agentManager.terminal.kill": "Avslutt terminal",
|
||||
"agentManager.terminal.add": "Ny terminal",
|
||||
"agentManager.terminal.empty": "Ingen terminal her ennå",
|
||||
"agentManager.terminal.start": "Start terminal",
|
||||
"agentManager.terminal.destination": "Velg hva terminalknappen åpner",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nowa karta terminala",
|
||||
"agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć",
|
||||
"agentManager.terminal.connectionError": "błąd połączenia terminala",
|
||||
"agentManager.terminal.kill": "Zakończ terminal",
|
||||
"agentManager.terminal.add": "Nowy terminal",
|
||||
"agentManager.terminal.empty": "Nie ma tu jeszcze terminala",
|
||||
"agentManager.terminal.start": "Uruchom terminal",
|
||||
"agentManager.terminal.destination": "Wybierz, co otwiera przycisk terminala",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Новая вкладка терминала",
|
||||
"agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть",
|
||||
"agentManager.terminal.connectionError": "ошибка подключения к терминалу",
|
||||
"agentManager.terminal.kill": "Завершить терминал",
|
||||
"agentManager.terminal.add": "Новый терминал",
|
||||
"agentManager.terminal.empty": "Здесь пока нет терминала",
|
||||
"agentManager.terminal.start": "Запустить терминал",
|
||||
"agentManager.terminal.destination": "Выберите, где будет открываться терминал",
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "แท็บเทอร์มินัลใหม่",
|
||||
"agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก",
|
||||
"agentManager.terminal.connectionError": "ข้อผิดพลาดการเชื่อมต่อเทอร์มินัล",
|
||||
"agentManager.terminal.kill": "หยุดเทอร์มินัล",
|
||||
"agentManager.terminal.add": "เทอร์มินัลใหม่",
|
||||
"agentManager.terminal.empty": "ยังไม่มีเทอร์มินัลที่นี่",
|
||||
"agentManager.terminal.start": "เริ่มเทอร์มินัล",
|
||||
"agentManager.terminal.destination": "เลือกว่าปุ่มเทอร์มินัลจะเปิดอะไร",
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Yeni Terminal Sekmesi",
|
||||
"agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın",
|
||||
"agentManager.terminal.connectionError": "terminal bağlantı hatası",
|
||||
"agentManager.terminal.kill": "Terminali sonlandır",
|
||||
"agentManager.terminal.add": "Yeni terminal",
|
||||
"agentManager.terminal.empty": "Burada henüz terminal yok",
|
||||
"agentManager.terminal.start": "Terminali başlat",
|
||||
"agentManager.terminal.destination": "Terminal düğmesinin ne açacağını seçin",
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Нова вкладка термінала",
|
||||
"agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити",
|
||||
"agentManager.terminal.connectionError": "помилка з'єднання термінала",
|
||||
"agentManager.terminal.kill": "Завершити термінал",
|
||||
"agentManager.terminal.add": "Новий термінал",
|
||||
"agentManager.terminal.empty": "Тут ще немає термінала",
|
||||
"agentManager.terminal.start": "Запустити термінал",
|
||||
"agentManager.terminal.destination": "Виберіть, що відкриватиме кнопка термінала",
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "新建终端标签页",
|
||||
"agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除",
|
||||
"agentManager.terminal.connectionError": "终端连接错误",
|
||||
"agentManager.terminal.kill": "终止终端",
|
||||
"agentManager.terminal.add": "新建终端",
|
||||
"agentManager.terminal.empty": "此处尚无终端",
|
||||
"agentManager.terminal.start": "启动终端",
|
||||
"agentManager.terminal.destination": "选择终端按钮的打开目标",
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "新增終端分頁",
|
||||
"agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除",
|
||||
"agentManager.terminal.connectionError": "終端連線錯誤",
|
||||
"agentManager.terminal.kill": "終止終端機",
|
||||
"agentManager.terminal.add": "新增終端機",
|
||||
"agentManager.terminal.empty": "此處尚無終端機",
|
||||
"agentManager.terminal.start": "啟動終端機",
|
||||
"agentManager.terminal.destination": "選擇終端機按鈕的開啟目標",
|
||||
|
||||
@@ -3,22 +3,33 @@
|
||||
*
|
||||
* Lives inside the shared `.am-diff-panel-wrapper` host next to the diff
|
||||
* and PR panels, so all three inspector modes share one resize handle
|
||||
* and one width. The header intentionally reuses the `.am-diff-header`
|
||||
* structure and metrics so switching modes does not shift the chrome.
|
||||
* 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
|
||||
* preserved across sidebar context switches for the webview's lifetime.
|
||||
* The strip stays visible even when empty so the `+` action 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 { Show, createEffect } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
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"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
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 { renderSideTerminalLayer } from "./render"
|
||||
import { TerminalTabChrome } from "./SortableTerminalTab"
|
||||
import type { TerminalStateControls } from "./state"
|
||||
|
||||
interface Props {
|
||||
@@ -27,9 +38,11 @@ interface Props {
|
||||
contextKey: Accessor<string>
|
||||
/** True while the inspector is in terminal mode. */
|
||||
visible: Accessor<boolean>
|
||||
/** Kill the terminal (or cancel its create) and hide. */
|
||||
onClose: () => void
|
||||
/** Empty-state action: create a side terminal for this context. */
|
||||
/** Make a terminal the visible one in the strip. */
|
||||
onSelect: (terminalId: string) => void
|
||||
/** Kill one terminal. */
|
||||
onClose: (terminalId: string) => void
|
||||
/** Create a new side terminal for this context. */
|
||||
onStart: () => void
|
||||
}
|
||||
|
||||
@@ -39,8 +52,26 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
createEffect(() => {
|
||||
panel.inert = !props.visible()
|
||||
})
|
||||
const side = () => props.state.side()
|
||||
const pending = () => props.state.pendingSide(props.contextKey()) !== undefined
|
||||
const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>()
|
||||
const sides = () => props.state.sidesForContext(props.contextKey())
|
||||
const ids = () => sides().map((term) => term.id)
|
||||
const pending = () => props.state.pendingSide(props.contextKey())
|
||||
const onDragStart = (event: DragEvent) => {
|
||||
const id = event.draggable?.id
|
||||
if (typeof id !== "string") return
|
||||
// Pin the overlay to the tab's width: the overlay container uses
|
||||
// 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
|
||||
setDragging({ id, width })
|
||||
}
|
||||
const onDragEnd = () => setDragging(undefined)
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
if (typeof from !== "string" || typeof to !== "string") return
|
||||
props.state.reorderSideDrag(props.contextKey(), from, to)
|
||||
}
|
||||
return (
|
||||
<section
|
||||
ref={panel}
|
||||
@@ -48,31 +79,86 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
aria-label={t("agentManager.tab.terminal")}
|
||||
aria-hidden={!props.visible()}
|
||||
>
|
||||
<div class="am-diff-header">
|
||||
<div class="am-diff-header-main">
|
||||
<Icon name="console" size="small" />
|
||||
<span class="am-diff-header-title">{side()?.title ?? t("agentManager.tab.terminal")}</span>
|
||||
</div>
|
||||
<div class="am-diff-header-actions">
|
||||
<Tooltip value={t("agentManager.terminal.kill")} placement="bottom">
|
||||
<div class="am-side-terminal-tabs">
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<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}
|
||||
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>
|
||||
</div>
|
||||
{/* Cursor-following clone of the dragged tab (same pattern as
|
||||
the top tab bar). The overlay is what makes the in-list
|
||||
original use solid-dnd's slot-compensated transform, so the
|
||||
dragged tab tracks the cursor without a jump/offset. The
|
||||
original stays dimmed in its slot via .am-tab-dragging. */}
|
||||
<DragOverlay>
|
||||
<Show when={dragging()}>
|
||||
{(tab) => (
|
||||
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
|
||||
<span class="am-tab-label">{props.state.title(tab().id) ?? t("agentManager.tab.terminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<div class="am-side-terminal-add">
|
||||
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.terminal.kill")}
|
||||
onClick={props.onClose}
|
||||
aria-label={t("agentManager.terminal.add")}
|
||||
onClick={props.onStart}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })}
|
||||
<Show when={props.visible() && pending() && !side()}>
|
||||
<Show when={props.visible() && sides().length === 0 && pending()}>
|
||||
<div class="am-side-terminal-state" role="status">
|
||||
<Spinner />
|
||||
<span>{t("common.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.visible() && !pending() && !side()}>
|
||||
<Show when={props.visible() && sides().length === 0 && !pending()}>
|
||||
<div class="am-side-terminal-state">
|
||||
<span class="am-side-terminal-empty">{t("agentManager.terminal.empty")}</span>
|
||||
<Button variant="primary" size="small" onClick={props.onStart}>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* Draggable tab chrome for an xterm terminal tab.
|
||||
* Tab chrome for xterm terminals.
|
||||
*
|
||||
* Shares the same hover/tooltip/close/right-click mechanism as the
|
||||
* session and review tab variants so users get consistent navigation
|
||||
* hints and context actions regardless of tab kind.
|
||||
* `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.
|
||||
*/
|
||||
|
||||
import { Component, Show, type JSX } from "solid-js"
|
||||
@@ -15,6 +18,70 @@ import { useLanguage } from "../../src/context/language"
|
||||
import { SortableTabContainer } from "../../src/components/chat/TabDnd"
|
||||
import { parseBindingTokens } from "../keybind-tokens"
|
||||
|
||||
export const TerminalTabChrome: Component<{
|
||||
label: string
|
||||
tooltip: string
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
active: boolean
|
||||
role?: "tab"
|
||||
selected?: boolean
|
||||
tabIndex?: number
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
onSelect: () => void
|
||||
onMiddleClick?: (e: MouseEvent) => void
|
||||
onClose: (e: MouseEvent) => void
|
||||
}> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
aria-selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={props.tooltip}
|
||||
keybind={props.keybind ?? ""}
|
||||
placement="bottom"
|
||||
gutter={8}
|
||||
class="am-tab-tooltip"
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<span class="am-tab-icon">
|
||||
<Icon name="console" size="small" />
|
||||
</span>
|
||||
<span class="am-tab-label">{props.label}</span>
|
||||
</span>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.tab.close")}
|
||||
keybind={props.closeKeybind ?? ""}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.tab.closeTab")}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableTerminalTab: Component<{
|
||||
id: string
|
||||
label: string
|
||||
@@ -36,51 +103,20 @@ export const SortableTerminalTab: Component<{
|
||||
<SortableTabContainer id={props.id}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
|
||||
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
aria-selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={props.tooltip}
|
||||
keybind={props.keybind ?? ""}
|
||||
placement="bottom"
|
||||
gutter={8}
|
||||
class="am-tab-tooltip"
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<span class="am-tab-icon">
|
||||
<Icon name="console" size="small" />
|
||||
</span>
|
||||
<span class="am-tab-label">{props.label}</span>
|
||||
</span>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.tab.close")}
|
||||
keybind={props.closeKeybind ?? ""}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.tab.closeTab")}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<TerminalTabChrome
|
||||
label={props.label}
|
||||
tooltip={props.tooltip}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
active={props.active}
|
||||
role={props.role}
|
||||
selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onSelect={props.onSelect}
|
||||
onMiddleClick={props.onMiddleClick}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content class="am-ctx-menu">
|
||||
|
||||
@@ -46,6 +46,11 @@ interface Props {
|
||||
* layer tracks this as `focusedId` so `Cmd+W` can target the
|
||||
* terminal that actually has the cursor. */
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
/** Reports OSC window-title escape codes (`ESC ] 0/1/2 ; title BEL`)
|
||||
* sent by the shell or running programs — fish sets it to the active
|
||||
* command, oh-my-zsh to user@host:cwd, vim to the file name. The
|
||||
* state layer mirrors it into the tab label. */
|
||||
onTitleChange?: (title: string) => void
|
||||
}
|
||||
|
||||
/** How long the ResizeObserver waits after the last size change before
|
||||
@@ -207,6 +212,10 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
host.addEventListener("focusin", onFocusIn)
|
||||
host.addEventListener("focusout", onFocusOut)
|
||||
|
||||
// OSC 0/1/2 window-title sequences → tab label. xterm parses and
|
||||
// strips these itself, so this only fires for real title codes.
|
||||
const disposeTitle = term.onTitleChange((title) => props.onTitleChange?.(title))
|
||||
|
||||
const ws = new WebSocket(props.wsUrl)
|
||||
ws.binaryType = "arraybuffer"
|
||||
let closed = false
|
||||
@@ -394,6 +403,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
clearTimeout(resizeTimer)
|
||||
ro.disconnect()
|
||||
disposeData.dispose()
|
||||
disposeTitle.dispose()
|
||||
try {
|
||||
ws.close()
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,11 +41,13 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
|
||||
const term = deps.terms.lookup().get(deps.id)
|
||||
if (!term) return null
|
||||
const isActive = () => deps.terms.activeId() === deps.id
|
||||
// Label and tooltip read through `terms.title` so OSC title changes
|
||||
// (shell/program escape codes) rename the tab live.
|
||||
return (
|
||||
<SortableTerminalTab
|
||||
id={deps.id}
|
||||
label={term.title}
|
||||
tooltip={term.title}
|
||||
label={deps.terms.title(deps.id) ?? term.title}
|
||||
tooltip={deps.terms.title(deps.id) ?? term.title}
|
||||
keybind={isActive() ? "" : deps.keybind()}
|
||||
closeKeybind={deps.closeKeybind()}
|
||||
active={isActive()}
|
||||
@@ -109,6 +111,7 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS
|
||||
focusSerial={focusSerial(props.state, term.id)}
|
||||
font={term.font}
|
||||
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
|
||||
onTitleChange={(title) => props.state.setTitle(term.id, title)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -126,7 +129,8 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS
|
||||
* terminal stays mounted, visibility is toggled via `opacity` /
|
||||
* `pointer-events` / `inert` only. The layer is scoped to
|
||||
* `contextKey` — side terminals from other contexts stay composed in
|
||||
* the background and never refit.
|
||||
* the background and never refit — and within a context only the
|
||||
* active strip tab's terminal is shown.
|
||||
*/
|
||||
export function renderSideTerminalLayer(props: {
|
||||
state: TerminalStateControls
|
||||
@@ -137,7 +141,10 @@ export function renderSideTerminalLayer(props: {
|
||||
<div class={`am-side-terminal-layer ${props.visible() ? "am-side-terminal-layer-active" : ""}`}>
|
||||
<For each={props.state.sides()}>
|
||||
{(term) => {
|
||||
const active = () => props.visible() && term.contextKey === props.contextKey()
|
||||
const active = () =>
|
||||
props.visible() &&
|
||||
term.contextKey === props.contextKey() &&
|
||||
props.state.sideActiveFor(term.contextKey) === term.id
|
||||
return (
|
||||
<div class={`am-terminal-slot ${active() ? "am-terminal-slot-visible" : ""}`} inert={!active()}>
|
||||
<TerminalTab
|
||||
@@ -147,6 +154,7 @@ export function renderSideTerminalLayer(props: {
|
||||
focusSerial={focusSerial(props.state, term.id)}
|
||||
font={term.font}
|
||||
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
|
||||
onTitleChange={(title) => props.state.setTitle(term.id, title)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -56,15 +56,15 @@ export function resolveVscodeTerminalRequest(
|
||||
|
||||
interface Handlers {
|
||||
requestSide(): void
|
||||
closeSide(): boolean
|
||||
closeSide(terminalId: string): boolean
|
||||
}
|
||||
|
||||
export interface SideTerminalDeps {
|
||||
handlers: Handlers
|
||||
/** True while the right-side inspector shows the terminal. */
|
||||
visible: Accessor<boolean>
|
||||
/** True while the side terminal itself holds DOM focus. */
|
||||
focused: Accessor<boolean>
|
||||
/** Id of the side terminal holding DOM focus, if any. */
|
||||
focusedId: Accessor<string | undefined>
|
||||
/** Leave terminal mode; the terminal stays alive in the background. */
|
||||
hide: () => void
|
||||
/** Move focus back to the chat composer. */
|
||||
@@ -96,7 +96,7 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
|
||||
const toggle = () => {
|
||||
if (deps.visible()) {
|
||||
const was = deps.focused()
|
||||
const was = deps.focusedId() !== undefined
|
||||
deps.hide()
|
||||
handoff(was)
|
||||
return
|
||||
@@ -104,12 +104,14 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
deps.handlers.requestSide()
|
||||
}
|
||||
|
||||
/** Kill the current context's side terminal (or cancel its in-flight
|
||||
* create) and hide the panel. */
|
||||
/** Kill the focused side terminal (Cmd/Ctrl+W). The panel stays open
|
||||
* on the remaining terminals, or on the empty state when this was
|
||||
* the last one. */
|
||||
const close = (): boolean => {
|
||||
const was = deps.focused()
|
||||
const done = deps.handlers.closeSide()
|
||||
if (done) handoff(was)
|
||||
const id = deps.focusedId()
|
||||
if (!id) return false
|
||||
const done = deps.handlers.closeSide(id)
|
||||
if (done) handoff(true)
|
||||
return done
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,9 @@ export interface TerminalFocusRequest {
|
||||
}
|
||||
|
||||
/** A create request for a side terminal that has not been answered yet.
|
||||
* `cancelled` is set when the user closes the panel while the PTY is
|
||||
* still starting; the late `created` answer is then closed again. */
|
||||
* Multiple creates can be in flight for the same context at once. */
|
||||
interface SideRequest {
|
||||
contextKey: string
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export interface TerminalStateControls {
|
||||
@@ -75,10 +73,14 @@ export interface TerminalStateControls {
|
||||
all: Accessor<TerminalTabStateWithContext[]>
|
||||
/** Every side terminal across every context (for the side-panel layer). */
|
||||
sides: Accessor<TerminalTabStateWithContext[]>
|
||||
/** The side terminal of the current context, if any. */
|
||||
side: Accessor<TerminalTabStateWithContext | undefined>
|
||||
/** The side terminal of an arbitrary context, if any. */
|
||||
sideForContext(contextKey: string): TerminalTabStateWithContext | undefined
|
||||
/** Every side terminal of the given context, in creation order. */
|
||||
sidesForContext(contextKey: string): TerminalTabStateWithContext[]
|
||||
/** Id of the active side terminal for a context. */
|
||||
sideActiveFor(contextKey: string): string | undefined
|
||||
/** Mark a side terminal as the visible one for its context. */
|
||||
setSideActive(contextKey: string, terminalId: string): void
|
||||
/** Id of the side terminal holding DOM focus in the current context, if any. */
|
||||
sideFocusedId(): string | undefined
|
||||
/** Context key for the current sidebar selection, or `undefined` when nothing is selected. */
|
||||
currentKey: Accessor<string | undefined>
|
||||
/** Context key for the side panel: like `currentKey` but unassigned
|
||||
@@ -96,6 +98,13 @@ export interface TerminalStateControls {
|
||||
requestFocus(id: string): void
|
||||
/** True when the given remembered tab id points to a live terminal for the given selection. */
|
||||
hasRemembered(selection: string | null, remembered: string | undefined): boolean
|
||||
/** Live display title for a terminal: the OSC-provided title when the
|
||||
* shell/program set one, otherwise the create-time title. */
|
||||
title(terminalId: string): string | undefined
|
||||
/** Record an OSC title change for a terminal. Kept outside the
|
||||
* terminal records so `<For>` reference stability (and therefore the
|
||||
* mounted xterm instances) is preserved. */
|
||||
setTitle(terminalId: string, title: string): void
|
||||
/**
|
||||
* Persist a new order for a context's terminals (webview-memory only —
|
||||
* terminals are ephemeral and never round-trip through the extension
|
||||
@@ -109,12 +118,16 @@ export interface TerminalStateControls {
|
||||
* was applied, false otherwise so the caller can fall through.
|
||||
*/
|
||||
reorderDrag(from: string, to: string): boolean
|
||||
/** Request id of the in-flight side-terminal create for a context. */
|
||||
pendingSide(contextKey: string): string | undefined
|
||||
/**
|
||||
* Apply a drag-over reorder within a context's side terminals (the
|
||||
* side-panel strip). Returns true when both ends are side terminals
|
||||
* of that context.
|
||||
*/
|
||||
reorderSideDrag(contextKey: string, from: string, to: string): boolean
|
||||
/** Request ids of the in-flight side-terminal creates for a context. */
|
||||
pendingSide(contextKey: string): boolean
|
||||
/** Mark a side-terminal create as in flight for a context. */
|
||||
beginSide(contextKey: string, createId: string): void
|
||||
/** Cancel the in-flight create; returns true when one was pending. */
|
||||
cancelSide(contextKey: string): boolean
|
||||
/** Settle a create request; returns it so the caller can validate. */
|
||||
completeSide(createId: string): SideRequest | undefined
|
||||
}
|
||||
@@ -149,10 +162,17 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
const [activeId, setActiveId] = createSignal<string | undefined>()
|
||||
const [focusedId, setFocusedId] = createSignal<string | undefined>()
|
||||
const [focusRequest, setFocusRequest] = createSignal<TerminalFocusRequest | undefined>()
|
||||
// OSC-provided titles, keyed by terminal id. Separate from the terminal
|
||||
// records on purpose: replacing a record would remount its xterm via
|
||||
// <For> reference inequality (see the module comment above).
|
||||
const [titles, setTitles] = createSignal<Record<string, string>>({})
|
||||
// Active side terminal per context.
|
||||
const [actives, setActives] = createSignal<Record<string, string>>({})
|
||||
let focusSerial = 0
|
||||
// In-flight side-terminal creates, keyed both ways: per context (what
|
||||
// the panel shows) and per request id (what the answer carries).
|
||||
const [pending, setPending] = createSignal<Record<string, string>>({})
|
||||
// the panel shows) and per request id (what the answer carries). A
|
||||
// context can have several creates in flight at once.
|
||||
const [pending, setPending] = createSignal<Record<string, string[]>>({})
|
||||
const requests = new Map<string, SideRequest>()
|
||||
|
||||
const currentKey = (): string | undefined => {
|
||||
@@ -195,8 +215,31 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return out
|
||||
}
|
||||
|
||||
const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side")
|
||||
const side = () => sideForContext(sideKey())
|
||||
const sidesForContext = (key: string) => (terminalsByContext()[key] ?? []).filter((t) => t.placement === "side")
|
||||
const sideActiveFor = (key: string) => actives()[key]
|
||||
const sideFocusedId = () => {
|
||||
const id = focusedId()
|
||||
if (!id) return undefined
|
||||
return sidesForContext(sideKey()).some((t) => t.id === id) ? id : undefined
|
||||
}
|
||||
|
||||
const setSideActive = (key: string, terminalId: string) => {
|
||||
setActives((prev) => (prev[key] === terminalId ? prev : { ...prev, [key]: terminalId }))
|
||||
}
|
||||
|
||||
const title = (terminalId: string): string | undefined => {
|
||||
const live = titles()[terminalId]
|
||||
if (live) return live
|
||||
const key = contextFor(terminalId)
|
||||
if (!key) return undefined
|
||||
return terminalsByContext()[key]?.find((t) => t.id === terminalId)?.title
|
||||
}
|
||||
|
||||
const setTitle = (terminalId: string, next: string) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed }))
|
||||
}
|
||||
|
||||
const lookup = () => new Map(current().map((t) => [t.id, t]))
|
||||
|
||||
@@ -218,9 +261,6 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
setTerminalsByContext((prev) => {
|
||||
const list = prev[key] ?? []
|
||||
if (list.some((t) => t.id === term.id)) return prev
|
||||
// One side terminal per context; the message handler dedupes via
|
||||
// pending requests, this guard covers stale double answers.
|
||||
if (term.placement === "side" && list.some((t) => t.placement === "side")) return prev
|
||||
const enriched: TerminalTabStateWithContext = { ...term, contextKey: key }
|
||||
return { ...prev, [key]: [...list, enriched] }
|
||||
})
|
||||
@@ -238,6 +278,24 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return next
|
||||
})
|
||||
if (focusedId() === terminalId) setFocusedId(undefined)
|
||||
// A removed active side terminal hands activation to the last
|
||||
// remaining one of its context, so the panel never shows a dead slot.
|
||||
if (removed?.placement === "side" && actives()[key] === terminalId) {
|
||||
const rest = sidesForContext(key)
|
||||
setActives((prev) => {
|
||||
const next = { ...prev }
|
||||
if (rest.length === 0) delete next[key]
|
||||
else next[key] = rest[rest.length - 1]!.id
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (titles()[terminalId] !== undefined) {
|
||||
setTitles((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[terminalId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -299,34 +357,58 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return true
|
||||
}
|
||||
|
||||
const pendingSide = (key: string) => pending()[key]
|
||||
|
||||
const beginSide = (key: string, createId: string) => {
|
||||
requests.set(createId, { contextKey: key, cancelled: false })
|
||||
setPending((prev) => ({ ...prev, [key]: createId }))
|
||||
}
|
||||
|
||||
const cancelSide = (key: string): boolean => {
|
||||
const id = pending()[key]
|
||||
if (!id) return false
|
||||
const request = requests.get(id)
|
||||
if (request) request.cancelled = true
|
||||
setPending((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
/**
|
||||
* Reorder the side terminals of a context by moving `from` to `to`'s
|
||||
* position (side-panel strip drag-and-drop). Tab terminals keep their
|
||||
* leading positions; only the side subset is reshuffled. The order
|
||||
* lives in the same `terminalsByContext` list, so it survives sidebar
|
||||
* context switches for the lifetime of the webview.
|
||||
*/
|
||||
const reorderSideDrag = (key: string, from: string, to: string): boolean => {
|
||||
const order = sidesForContext(key).map((t) => t.id)
|
||||
const fi = order.indexOf(from)
|
||||
const ti = order.indexOf(to)
|
||||
if (fi === -1 || ti === -1 || fi === ti) return false
|
||||
const next = [...order]
|
||||
next.splice(fi, 1)
|
||||
next.splice(ti, 0, from)
|
||||
setTerminalsByContext((prev) => {
|
||||
const list = prev[key]
|
||||
if (!list || list.length === 0) return prev
|
||||
const tabs = list.filter((t) => t.placement === "tab")
|
||||
const sides = list.filter((t) => t.placement === "side")
|
||||
const byId = new Map(sides.map((t) => [t.id, t]))
|
||||
const moved: TerminalTabStateWithContext[] = []
|
||||
for (const id of next) {
|
||||
const t = byId.get(id)
|
||||
if (t) moved.push(t)
|
||||
}
|
||||
// Fresh terminals that appeared mid-drag keep their tail position.
|
||||
for (const t of sides) if (!moved.includes(t)) moved.push(t)
|
||||
const ordered = [...tabs, ...moved]
|
||||
if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev
|
||||
return { ...prev, [key]: ordered }
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0
|
||||
|
||||
const beginSide = (key: string, createId: string) => {
|
||||
requests.set(createId, { contextKey: key })
|
||||
setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] }))
|
||||
}
|
||||
|
||||
const completeSide = (createId: string): SideRequest | undefined => {
|
||||
const request = requests.get(createId)
|
||||
if (!request) return undefined
|
||||
requests.delete(createId)
|
||||
setPending((prev) => {
|
||||
if (prev[request.contextKey] !== createId) return prev
|
||||
const list = (prev[request.contextKey] ?? []).filter((id) => id !== createId)
|
||||
if (list.length === (prev[request.contextKey]?.length ?? 0)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[request.contextKey]
|
||||
if (list.length === 0) delete next[request.contextKey]
|
||||
else next[request.contextKey] = list
|
||||
return next
|
||||
})
|
||||
return request
|
||||
@@ -341,8 +423,10 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
current,
|
||||
all,
|
||||
sides,
|
||||
side,
|
||||
sideForContext,
|
||||
sidesForContext,
|
||||
sideActiveFor,
|
||||
setSideActive,
|
||||
sideFocusedId,
|
||||
currentKey,
|
||||
sideKey,
|
||||
activeId,
|
||||
@@ -352,11 +436,13 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
focusRequest,
|
||||
requestFocus,
|
||||
hasRemembered,
|
||||
title,
|
||||
setTitle,
|
||||
reorder,
|
||||
reorderDrag,
|
||||
reorderSideDrag,
|
||||
pendingSide,
|
||||
beginSide,
|
||||
cancelSide,
|
||||
completeSide,
|
||||
}
|
||||
}
|
||||
@@ -376,8 +462,6 @@ export interface TerminalHandlerDeps {
|
||||
onRemove?: () => void
|
||||
/** Reveal the right-side inspector in terminal mode. */
|
||||
onShowSide: (contextKey: string) => void
|
||||
/** Leave terminal mode without killing the terminal. */
|
||||
onHideSide: () => void
|
||||
/** Resolve the current sidebar selection for the new-terminal helper. */
|
||||
getSelection: () => string | null
|
||||
/** Sentinel value for the LOCAL sidebar selection. */
|
||||
@@ -418,31 +502,42 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the side panel and create-or-focus the context's side
|
||||
* terminal. Reuses the existing terminal when one is alive, dedupes
|
||||
* against an in-flight create, and never touches the tab strip or
|
||||
* the chat session.
|
||||
* Always create a fresh side terminal for the current context (the
|
||||
* panel's `+` action and empty state). Multiple creates may be in
|
||||
* flight at once; each lands as its own tab in the panel strip.
|
||||
*/
|
||||
const requestSide = () => {
|
||||
const addSide = () => {
|
||||
const key = deps.state.sideKey()
|
||||
deps.onShowSide(key)
|
||||
const existing = deps.state.sideForContext(key)
|
||||
if (existing) {
|
||||
deps.state.requestFocus(existing.id)
|
||||
return
|
||||
}
|
||||
if (deps.state.pendingSide(key)) return
|
||||
const id = newId()
|
||||
deps.state.beginSide(key, id)
|
||||
const sel = deps.getSelection()
|
||||
deps.postMessage({
|
||||
type: "agentManager.terminal.create",
|
||||
createId: id,
|
||||
placement: "side",
|
||||
worktreeId: sel === null || sel === deps.LOCAL ? null : sel,
|
||||
worktreeId: key === deps.LOCAL ? null : key,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the side panel and focus the context's active side terminal,
|
||||
* creating one when the context has none. Never touches the tab strip
|
||||
* or the chat session.
|
||||
*/
|
||||
const requestSide = () => {
|
||||
const key = deps.state.sideKey()
|
||||
deps.onShowSide(key)
|
||||
const existing = deps.state.sidesForContext(key)
|
||||
if (existing.length > 0) {
|
||||
const active = deps.state.sideActiveFor(key) ?? existing[existing.length - 1]!.id
|
||||
deps.state.setSideActive(key, active)
|
||||
deps.state.requestFocus(active)
|
||||
return
|
||||
}
|
||||
if (deps.state.pendingSide(key)) return
|
||||
addSide()
|
||||
}
|
||||
|
||||
const closeTerminal = (terminalId: string) => {
|
||||
deps.onRemove?.()
|
||||
const ids = deps.tabIds()
|
||||
@@ -477,19 +572,29 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the current context's side terminal and hide the panel. With
|
||||
* a create still in flight, cancels it instead — the late answer is
|
||||
* closed by the message handler.
|
||||
* Kill one side terminal. The panel stays open on the remaining
|
||||
* terminals (or the empty state when this was the last one) — hiding
|
||||
* is the toggle's job, not the close button's. Active-tab fallback
|
||||
* is handled by the state layer.
|
||||
*/
|
||||
const closeSide = () => {
|
||||
const term = deps.state.side()
|
||||
deps.onHideSide()
|
||||
if (!term) return deps.state.cancelSide(deps.state.sideKey())
|
||||
deps.state.remove(term.id)
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId: term.id })
|
||||
const closeSide = (terminalId: string): boolean => {
|
||||
// Validate before mutating: dropping a non-side record here would
|
||||
// unmount its xterm while the backend PTY leaks (no close sent).
|
||||
const term = deps.state.sides().find((t) => t.id === terminalId)
|
||||
if (!term) return false
|
||||
deps.state.remove(terminalId)
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
|
||||
return true
|
||||
}
|
||||
|
||||
/** Make a side terminal the visible one in its panel and focus it. */
|
||||
const selectSide = (terminalId: string) => {
|
||||
const key = deps.state.contextFor(terminalId)
|
||||
if (!key) return
|
||||
deps.state.setSideActive(key, terminalId)
|
||||
deps.state.requestFocus(terminalId)
|
||||
}
|
||||
|
||||
const middleClick = (terminalId: string, e: MouseEvent) => {
|
||||
if (e.button !== 1) return
|
||||
e.preventDefault()
|
||||
@@ -504,7 +609,18 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
return true
|
||||
}
|
||||
|
||||
return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive }
|
||||
return {
|
||||
closeTerminal,
|
||||
closeSide,
|
||||
selectSide,
|
||||
middleClick,
|
||||
activate,
|
||||
deactivate,
|
||||
requestNew,
|
||||
requestSide,
|
||||
addSide,
|
||||
closeActive,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TerminalMessageHandlerDeps {
|
||||
@@ -545,14 +661,17 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
|
||||
}
|
||||
if (msg.placement === "side") {
|
||||
// Side terminals are answered to a specific pending request. A
|
||||
// missing, cancelled, or context-mismatched request means the user
|
||||
// already moved on — close the PTY again instead of leaking it.
|
||||
// missing or context-mismatched request means the webview was
|
||||
// reloaded (or the context is gone) — close the PTY again instead
|
||||
// of leaking it.
|
||||
const request = deps.state.completeSide(msg.createId)
|
||||
if (!request || request.cancelled || request.contextKey !== contextKey) {
|
||||
if (!request || request.contextKey !== contextKey) {
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId })
|
||||
return
|
||||
}
|
||||
deps.state.add(msg.worktreeId, term)
|
||||
// The newest terminal becomes the visible one in its panel.
|
||||
deps.state.setSideActive(contextKey, msg.terminalId)
|
||||
deps.onSideCreated?.(contextKey, msg.terminalId)
|
||||
return
|
||||
}
|
||||
@@ -583,8 +702,6 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) {
|
||||
}
|
||||
if (msg.type === "agentManager.terminal.error") {
|
||||
const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined
|
||||
// Errors for requests the user already cancelled are noise.
|
||||
if (request?.cancelled) return true
|
||||
if (request) deps.onSideError?.(request.contextKey)
|
||||
deps.showError(msg.message)
|
||||
return true
|
||||
|
||||
@@ -27,13 +27,13 @@ export const ConstrainDragYAxis: Component = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
export const SortableTabContainer: ParentComponent<{ id: string }> = (props) => {
|
||||
export const SortableTabContainer: ParentComponent<{ id: string; class?: string }> = (props) => {
|
||||
const sortable = createSortable(props.id)
|
||||
void sortable
|
||||
return (
|
||||
<div
|
||||
use:sortable
|
||||
class="am-tab-sortable"
|
||||
class={props.class ?? "am-tab-sortable"}
|
||||
classList={{ "am-tab-dragging": sortable.isActiveDraggable }}
|
||||
data-tab-id={props.id}
|
||||
>
|
||||
|
||||
@@ -933,9 +933,9 @@ export const TabBarSingleTab: Story = {
|
||||
}
|
||||
|
||||
// Side terminal panel inside the real inspector host chain, empty state —
|
||||
// no live PTY, so the start affordance renders. The header reuses the
|
||||
// .am-diff-header metrics so the a11y/screenshot baseline also guards the
|
||||
// alignment against the diff panel chrome.
|
||||
// no live PTY, so the start affordance renders. The tab strip header keeps
|
||||
// the .am-diff-header height so the a11y/screenshot baseline also guards
|
||||
// the alignment against the diff panel chrome.
|
||||
export const SideTerminalPanelEmpty: Story = {
|
||||
name: "Side terminal panel — empty",
|
||||
render: () => {
|
||||
@@ -953,6 +953,47 @@ export const SideTerminalPanelEmpty: Story = {
|
||||
state={state}
|
||||
contextKey={() => LOCAL}
|
||||
visible={() => true}
|
||||
onSelect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onStart={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// Tab strip with several side terminals: the active one shows the X close
|
||||
// button, the others reveal it on hover. Terminals point at a dead port —
|
||||
// xterm renders its connection-error notice inside the panel, which keeps
|
||||
// the story self-contained without a live PTY.
|
||||
export const SideTerminalPanelTabs: Story = {
|
||||
name: "Side terminal panel — tabs",
|
||||
render: () => {
|
||||
const state = createTerminalState(() => LOCAL)
|
||||
const font = { fontFamily: "monospace", fontSize: 12 }
|
||||
state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://127.0.0.1:1/a", font, placement: "side" })
|
||||
state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://127.0.0.1:1/b", font, placement: "side" })
|
||||
state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://127.0.0.1:1/c", font, placement: "side" })
|
||||
state.setSideActive(LOCAL, "terminal:two")
|
||||
state.setTitle("terminal:two", "npm run dev")
|
||||
return (
|
||||
<StoryProviders noPadding>
|
||||
<div class="am-detail-stack" style={{ height: "420px" }}>
|
||||
<div class="am-detail-content am-detail-split">
|
||||
<div class="am-main-pane" style={{ padding: "24px", color: "var(--text-weak)" }}>
|
||||
Agent session stays visible beside the terminal.
|
||||
</div>
|
||||
<div class="am-diff-resize" style={{ width: "360px" }}>
|
||||
<div class="am-diff-panel-wrapper">
|
||||
<SideTerminalPanel
|
||||
state={state}
|
||||
contextKey={() => LOCAL}
|
||||
visible={() => true}
|
||||
onSelect={(id) => state.setSideActive(LOCAL, id)}
|
||||
onClose={() => undefined}
|
||||
onStart={() => undefined}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user