From 4436db67cad0c51c5fe9c9f8143617ee0ce2e7fd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 26 Oct 2026 20:27:51 -0700 Subject: [PATCH 1/3] feat(app): show a background shell's output from the pill --- .../component-tests/activity-chips.spec.ts | 26 ++ packages/app/src/runtime/i18n/en.ts | 8 + .../src/session/composer/activity-chips.css | 105 ++++++ .../composer/activity-chips.stories.tsx | 10 +- .../src/session/composer/activity-chips.tsx | 305 +++++++++++++++--- packages/app/src/session/composer/region.tsx | 44 ++- .../session-composer-region-controller.ts | 2 + .../app/src/session/composer/shell-panel.ts | 120 +++++++ packages/app/src/session/story-model.tsx | 32 +- packages/app/test-browser/shell-panel.test.ts | 211 ++++++++++++ packages/session-ui/package.json | 2 + 11 files changed, 799 insertions(+), 66 deletions(-) create mode 100644 packages/app/src/session/composer/shell-panel.ts create mode 100644 packages/app/test-browser/shell-panel.test.ts diff --git a/packages/app/component-tests/activity-chips.spec.ts b/packages/app/component-tests/activity-chips.spec.ts index dcc7c882c8eb..9d3d1077ddb9 100644 --- a/packages/app/component-tests/activity-chips.spec.ts +++ b/packages/app/component-tests/activity-chips.spec.ts @@ -19,3 +19,29 @@ story("shows how long each background item has been running, ticking while open" await expect(elapsed).toHaveText(["13s", "1m 06s", "1h 02m", "5m 42s"]) expect(await page.locator('[data-slot="session-activity-panel"]').evaluate((node) => node.clientWidth)).toBe(width) }) + +story("opens a background shell's output in the menu and returns focus on the way out", async ({ mount, page }) => { + await mount("session-activity-chips--subagents-and-shells") + + const chip = page.locator('[data-slot="session-activity-chip"]') + const panel = page.locator('[data-slot="session-activity-panel"]') + await chip.click() + await panel.getByRole("menuitem", { name: /bun test --watch/ }).click() + + // The story's shells print `$ ` and `Ready.`. + await expect(panel.locator('[data-slot="session-activity-shell-output"]')).toHaveText(/\$ shl_test\s+Ready\./) + await expect(panel.locator('[data-slot="session-activity-back"]')).toBeFocused() + + await page.keyboard.press("Enter") + await expect(panel.getByRole("menuitem", { name: /bun test --watch/ })).toBeFocused() + + await panel.getByRole("menuitem", { name: /bun test --watch/ }).click() + await expect(panel.locator('[data-slot="session-activity-back"]')).toBeFocused() + await page.keyboard.press("Escape") + await expect(panel).toBeHidden() + await expect(chip).toBeFocused() + + // Closing forgets the shell, so the menu opens on the list again. + await chip.click() + await expect(panel).toHaveAttribute("data-view", "list") +}) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index d759e2a66657..8e4540b01873 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -628,6 +628,14 @@ export const dict = { "session.activity.subagents.other": "{{count}} subagents", "session.activity.shells.one": "{{count}} shell", "session.activity.shells.other": "{{count}} shells", + "session.activity.shell.back": "Back to background work", + "session.activity.shell.stop": "Stop", + "session.activity.shell.stopping": "Stopping…", + "session.activity.shell.exited": "Exited", + "session.activity.shell.exitCode": "Exited with code {{code}}", + "session.activity.shell.timeout": "Timed out", + "session.activity.shell.killed": "Stopped", + "session.activity.shell.empty": "No output yet", "session.activity.summary": "{{subagents}} · {{shells}}", "session.activity.section.subagents": "Subagents", "session.activity.section.shells": "Shells", diff --git a/packages/app/src/session/composer/activity-chips.css b/packages/app/src/session/composer/activity-chips.css index 033a14c9521c..21e0f79e8281 100644 --- a/packages/app/src/session/composer/activity-chips.css +++ b/packages/app/src/session/composer/activity-chips.css @@ -142,6 +142,111 @@ } } + /* One shell: a wider panel with its header row over the tail of its output. Switching views changes size at once. */ + &[data-view="shell"] { + display: flex; + flex-direction: column; + width: min(560px, calc(100vw - 56px)); + max-width: none; + max-height: min(320px, var(--kb-popper-content-available-height, 320px)); + overflow: hidden; + } + + [data-slot="session-activity-shell-header"] { + display: flex; + flex: none; + align-items: center; + gap: 8px; + min-height: 28px; + padding-inline-end: 2px; + + [data-component="menu-v2-item"], + [data-slot="menu-v2-item-content"] { + display: flex; + flex: none; + } + + [data-component="menu-v2-item"] { + padding-inline: 6px; + } + + /* Matches the list's glyph column, so the arrow sits where the row icons were. */ + [data-slot="session-activity-back"] { + width: 28px; + justify-content: center; + --menu-v2-fg: var(--v2-icon-icon-muted); + + [data-slot="menu-v2-item-content"] { + flex: none; + } + + svg:dir(rtl) { + transform: scaleX(-1); + } + } + + [data-slot="session-activity-shell-stop"][aria-disabled="true"] { + --menu-v2-fg: var(--v2-text-text-muted); + } + } + + [data-slot="session-activity-shell-command"] { + font-family: var(--v2-font-family-code); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + text-align: left; + } + + [data-slot="session-activity-shell-status"] { + flex: none; + padding-inline: 6px; + font-size: 13px; + line-height: var(--line-height-compact); + white-space: nowrap; + color: var(--v2-text-text-muted); + + &[data-failed] { + color: var(--v2-state-fg-danger); + } + } + + [data-slot="session-activity-shell-output"] { + min-height: 0; + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + margin-top: 4px; + padding: 8px 10px; + border-radius: 6px; + background: var(--v2-background-bg-base); + + pre { + margin: 0; + text-align: left; + } + + code { + display: block; + font-family: var(--v2-font-family-code); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + white-space: pre-wrap; + overflow-wrap: anywhere; + } + } + + [data-slot="session-activity-shell-empty"] { + font-size: 13px; + line-height: var(--line-height-compact); + color: var(--v2-text-text-muted); + } + :root[data-motion="reduce"] & { animation: none; } diff --git a/packages/app/src/session/composer/activity-chips.stories.tsx b/packages/app/src/session/composer/activity-chips.stories.tsx index d4300b18592d..0100a4f12717 100644 --- a/packages/app/src/session/composer/activity-chips.stories.tsx +++ b/packages/app/src/session/composer/activity-chips.stories.tsx @@ -64,6 +64,11 @@ type PreviewArgs = { stage: number; subagents: number; shells: number } // Driven by args, so moving between these stories updates the same capsule and its transitions play. function Preview(props: PreviewArgs) { const value = createMemo(() => liveDocument(stages[props.stage] ?? stages[0])) + // Built once per count, so each item keeps the start time it had when it appeared. + const activity = createMemo(() => ({ + subagents: Array.from({ length: props.subagents }, (_, index) => subagentItem(index)), + shells: Array.from({ length: props.shells }, (_, index) => shellItem(index)), + })) onMount(() => pin("auto")) createEffect(on(value, () => pin("smooth"), { defer: true })) @@ -74,10 +79,7 @@ function Preview(props: PreviewArgs) { description="opencode · activity chips" document={value()} timelineDetail={timelineDetail} - activity={{ - subagents: Array.from({ length: props.subagents }, (_, index) => subagentItem(index)), - shells: Array.from({ length: props.shells }, (_, index) => shellItem(index)), - }} + activity={activity()} /> ) } diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index 4b0842339afe..9c5bfa0ff65d 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -2,10 +2,13 @@ import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" -import { createEffect, createSignal, For, onCleanup, Show } from "solid-js" +import type { ShellOutputInput, ShellOutputOutput } from "@opencode/client/promise" +import { stripAnsi } from "@opencode/session-ui/tools/ansi" +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { useSettings } from "@/settings/model" import { useComposerLaneAlignment } from "./composer-lane" +import { createShellPanel, createShellTail, type ShellEnd } from "./shell-panel" import "./activity-chips.css" export type SessionActivityItem = { @@ -47,14 +50,33 @@ export function formatElapsed(ms: number, t: (key: DurationKey, params: Record Promise + ended: Parameters[0]["ended"] + } onOpen: (item: SessionActivityItem) => void - onStop: (item: SessionActivityItem) => void + onStop: (item: SessionActivityItem) => Promise + // The menu closed with nothing left running, so the capsule and its trigger are gone; focus goes to the composer. + onEmpty: () => void }) { const language = useLanguage() const settings = useSettings() + const [open, setOpen] = createSignal(false) + const panel = createShellPanel({ shells: () => props.shells, stop: props.onStop, ended: props.shell.ended }) + + // A shell that ended while its panel is open still counts until the menu closes. + const held = () => { + const shell = panel.state.shell + + return open() && shell && !props.shells.some((item) => item.key === shell.key) ? 1 : 0 + } + + const counts = () => ({ subagents: props.subagents.length, shells: props.shells.length + held() }) + const active = () => - settings.general.backgroundActivity() !== "off" && (props.subagents.length > 0 || props.shells.length > 0) + settings.general.backgroundActivity() !== "off" && (counts().subagents > 0 || counts().shells > 0) const alignment = useComposerLaneAlignment() @@ -65,17 +87,18 @@ export function SessionActivityChips(props: { const row = (item: SessionActivityItem) => ({ "data-stoppable": stoppable(item) || undefined, - onSelect: () => props.onOpen(item), + // A shell opens its own panel in place; a subagent opens where it runs. + closeOnSelect: item.type !== "shell", + onSelect: () => (item.type === "shell" ? panel.open(item) : props.onOpen(item)), onKeyDown: (event: KeyboardEvent) => { if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return event.preventDefault() - props.onStop(item) + void props.onStop(item) }, }) // One clock for every row, ticking only while the panel is open. - const [open, setOpen] = createSignal(false) const [now, setNow] = createSignal(Date.now()) createEffect(() => { if (!open() || !active()) return @@ -85,6 +108,73 @@ export function SessionActivityChips(props: { onCleanup(() => clearInterval(timer)) }) + const rows = new Map() + let content: HTMLElement | undefined + let back: HTMLElement | undefined + + // Focus moves a frame late: the menu refocuses itself when the selected row unmounts. One pending move at a time. + let frame = 0 + const later = (move: () => void) => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(move) + } + onCleanup(() => cancelAnimationFrame(frame)) + + // Opening a shell moves focus to its back arrow. + createEffect( + on( + () => panel.state.shell?.key, + (key) => key && later(() => open() && back?.focus()), + { defer: true }, + ), + ) + + // Going back returns focus to that shell's row, or to the first row once the shell has ended and left the list. With + // nothing left to list, going back closes the menu. + const goBack = () => { + const key = panel.state.shell?.key + + if (props.subagents.length + props.shells.length === 0) return changeOpen(false) + panel.back() + later(() => { + if (!open()) return + const row = key ? rows.get(key) : undefined + + if (row?.isConnected) return row.focus() + content?.querySelector('[role="menuitem"]')?.focus() + }) + } + + // Stop gives way to how the shell ended; keep focus in the panel when it held Stop. + createEffect( + on( + () => panel.state.end, + (end) => { + if (!end || !back) return + const active = document.activeElement + + if (!active || active === document.body || !active.isConnected || active.matches('[role="menu"]')) back.focus() + }, + ), + ) + + // The menu always opens on the list, and closing forgets the shell so nothing follows it while the menu is shut. + const changeOpen = (value: boolean) => { + const was = open() + setOpen(value) + + if (value || !was) return + panel.back() + cancelAnimationFrame(frame) + + if (props.subagents.length + props.shells.length > 0) return + later(() => { + const active = document.activeElement + + if (!active || active === document.body || !active.isConnected) props.onEmpty() + }) + } + // The run time and the stop button share the row's end; hovering a stoppable row swaps one for the other. const end = (item: SessionActivityItem) => ( @@ -117,13 +207,13 @@ export function SessionActivityChips(props: { onPointerUp={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation() - props.onStop(item) + void props.onStop(item) }} /> ) - const label = () => language.activitySummary({ subagents: props.subagents.length, shells: props.shells.length }) + const label = () => language.activitySummary(counts()) // Each session mounts its own composer, so a capsule that is already there on a switch stays at rest; only work // that starts while the session is open fades in. @@ -131,13 +221,13 @@ export function SessionActivityChips(props: { createEffect(() => { if (active()) return setEnter(true) - setOpen(false) + changeOpen(false) }) return (
- + - {language.activitySummaryRich( - { subagents: props.subagents.length, shells: props.shells.length }, - (text) => ( - - {text} - - ), - )} + {language.activitySummaryRich(counts(), (text) => ( + + {text} + + ))} { if (!(event.target instanceof HTMLElement)) return const row = event.target.closest('[data-component="menu-v2-item"]') @@ -171,38 +260,104 @@ export function SessionActivityChips(props: { queueMicrotask(() => row.scrollIntoView({ block: "nearest", inline: "nearest" })) }} > - - {(item) => ( - - - - - {item.agent - ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` - : language.t("ui.tool.agent.default")} - - - - {item.label} - - {end(item)} - - )} - - - {(item) => ( - - - - {item.label} - - {end(item)} - + + + {(item) => ( + + + + + {item.agent + ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` + : language.t("ui.tool.agent.default")} + + + + {item.label} + + {end(item)} + + )} + + + {(item) => ( + rows.set(item.key, el)}> + + + {item.label} + + {end(item)} + + )} + + + } + > + {(shell) => ( + <> +
+ (back = el)} + > + + + + {shell().label} + + + {formatElapsed((panel.state.end?.completed ?? now()) - shell().started, language.t)} + + + {language.t( + panel.state.stopping ? "session.activity.shell.stopping" : "session.activity.shell.stop", + )} + + } + > + {(end) => ( + + {endLabel(end(), language.t)} + + )} + +
+ + )} -
+
@@ -210,3 +365,55 @@ export function SessionActivityChips(props: { ) } + +function endLabel(end: ShellEnd, t: ReturnType["t"]) { + if (end.status === "timeout") return t("session.activity.shell.timeout") + if (end.status === "killed") return t("session.activity.shell.killed") + if (end.exit === undefined) return t("session.activity.shell.exited") + + return t("session.activity.shell.exitCode", { code: String(end.exit) }) +} + +const TAIL_LINES = 14 + +// The last lines of a shell's output, read the way the timeline reads it: escape codes stripped, carriage returns as +// line breaks. +function ShellTail(props: { + id: string + directory: string + running: boolean + load: (input: ShellOutputInput) => Promise +}) { + const language = useLanguage() + const output = createShellTail({ + id: () => props.id, + directory: () => props.directory, + running: () => props.running, + load: props.load, + }) + + const tail = createMemo(() => + stripAnsi(output()).replace(/\r\n?/g, "\n").trimEnd().split("\n").slice(-TAIL_LINES).join("\n"), + ) + + let scroller: HTMLDivElement | undefined + // Wrapped lines can outgrow the box; keep the newest output in view. + createEffect( + on(tail, () => { + if (scroller) scroller.scrollTop = scroller.scrollHeight + }), + ) + + return ( +
+ {language.t("session.activity.shell.empty")}} + > +
+          {tail()}
+        
+
+
+ ) +} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index ff28eac76cad..25477dc72263 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -1,4 +1,5 @@ import type { SessionUserActions } from "@opencode/session-ui/message" +import type { ShellOutputInput } from "@opencode/client/promise" import { getFilename, sameDirectory } from "@opencode/util/path" import { useDialog } from "@opencode/ui/context/dialog" import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view" @@ -313,6 +314,7 @@ export function createActiveSessionRegion(input: { centered: input.screen.centered, onResponseSubmit: input.timeline.actions.resume, openParent, + focusPrompt: () => focusPrompt(), setPromptRef: (element: HTMLDivElement) => { promptRef = element }, @@ -470,17 +472,38 @@ export function ActiveSessionComposerRegion(props: { ? server.api.session.interrupt({ sessionID: item.sessionID }) : server.api.shell.remove({ id: item.target, - location: { directory: data.shell.get(item.target)?.location.directory }, + location: { directory: data.shell.get(item.target)?.location.directory ?? location().directory }, }) - void request.catch((error) => - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(error, language.t("common.requestFailed")), - }), + return request.then( + () => true, + (error) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(error, language.t("common.requestFailed")), + }) + + return false + }, ) } + const shell = { + get directory() { + return location().directory + }, + output: (input: ShellOutputInput) => server.api.shell.output(input), + ended: (id: string) => + server.api.shell.get({ id, location: { directory: location().directory } }).then( + (response) => ({ + status: response.data.status === "running" ? ("killed" as const) : response.data.status, + exit: response.data.exit, + completed: response.data.time.completed, + }), + () => undefined, + ), + } + const providers = useProviders(() => props.session.data.info()?.location.directory) const modelLabels = createMemo(() => @@ -537,7 +560,14 @@ export function ActiveSessionComposerRegion(props: { above={ - + diff --git a/packages/app/src/session/composer/session-composer-region-controller.ts b/packages/app/src/session/composer/session-composer-region-controller.ts index f07f5ddc1328..5fe0e7a7c6c1 100644 --- a/packages/app/src/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/session/composer/session-composer-region-controller.ts @@ -7,6 +7,7 @@ export function createSessionComposerRegionController(input: { centered: Accessor onResponseSubmit: () => void openParent: () => void + focusPrompt: () => void setPromptRef: (el: HTMLDivElement) => void setDockRef: (el: HTMLDivElement) => void }) { @@ -15,6 +16,7 @@ export function createSessionComposerRegionController(input: { centered: input.centered, onResponseSubmit: input.onResponseSubmit, openParent: input.openParent, + focusPrompt: input.focusPrompt, setPromptRef: input.setPromptRef, setDockRef: input.setDockRef, parentID: input.parentID, diff --git a/packages/app/src/session/composer/shell-panel.ts b/packages/app/src/session/composer/shell-panel.ts new file mode 100644 index 000000000000..14276d448b32 --- /dev/null +++ b/packages/app/src/session/composer/shell-panel.ts @@ -0,0 +1,120 @@ +import type { ShellOutputInput, ShellOutputOutput } from "@opencode/client/promise" +import { followShellOutput } from "@opencode/session-ui/tools/shell-output" +import { createEffect, createSignal, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import type { SessionActivityItem } from "./activity-chips" + +export type ShellEnd = { + status: "exited" | "timeout" | "killed" + exit?: number + // When the shell stopped, so its run time freezes there. + completed: number +} + +// The background work menu shows either the list or one shell. A shell that ends while it is shown keeps its panel, +// with how it ended, until the user goes back or closes the menu. +export function createShellPanel(input: { + shells: () => readonly SessionActivityItem[] + stop: (item: SessionActivityItem) => Promise + // How a shell that left the running list ended; undefined once the server no longer knows it. + ended: (id: string) => Promise<(Omit & { completed?: number }) | undefined> +}) { + // `ending` covers the wait for how a shell that left the list ended. + const [state, setState] = createStore<{ + shell?: SessionActivityItem + stopping: boolean + ending: boolean + end?: ShellEnd + }>({ stopping: false, ending: false }) + + const shown = (key: string) => state.shell?.key === key + + createEffect( + on( + () => [state.shell, state.end, state.stopping, state.ending, input.shells()] as const, + ([shell, end, stopping, ending, shells]) => { + if (!shell || end || stopping || ending || shells.some((item) => item.key === shell.key)) return + const key = shell.key + setState("ending", true) + + void input.ended(shell.target).then((info) => { + if (!shown(key) || state.end) return + // Removing a running shell ends it as killed, and the server forgets it. + setState({ + ending: false, + end: { + status: info?.status ?? "killed", + exit: info?.exit, + completed: info?.completed ?? Date.now(), + }, + }) + }) + }, + ), + ) + + return { + state, + open(item: SessionActivityItem) { + setState({ shell: item, stopping: false, ending: false, end: undefined }) + }, + back() { + setState({ shell: undefined, stopping: false, ending: false, end: undefined }) + }, + stop() { + const item = state.shell + + // A shell that already left the list is ending on its own; stopping it now would report it as stopped. + if (!item || state.end || state.stopping || !input.shells().some((shell) => shell.key === item.key)) return + setState("stopping", true) + void input.stop(item).then((stopped) => { + if (!shown(item.key)) return + if (stopped && !state.end) setState("end", { status: "killed", completed: Date.now() }) + setState("stopping", false) + }) + }, + } +} + +// Follows one shell's output. When the shell ends the follow restarts for its final snapshot, but a first read still in +// flight is left to land: a stopped shell's output is deleted, so a fresh read from the start would find nothing. +export function createShellTail(input: { + id: () => string + directory: () => string + running: () => boolean + load: (input: ShellOutputInput) => Promise +}) { + const [output, setOutput] = createSignal("") + const retiring = new Set<() => void>() + + createEffect(() => { + const id = input.id() + let loaded = false + let retired = false + const dispose = () => { + retiring.delete(dispose) + stop() + } + const stop = followShellOutput({ + id, + directory: input.directory(), + running: input.running(), + load: input.load, + onOutput: (text) => { + loaded = true + if (!retired || input.id() === id) setOutput(text) + if (retired) dispose() + }, + }) + + onCleanup(() => { + retired = true + if (loaded) return dispose() + retiring.add(dispose) + }) + }) + + onCleanup(() => retiring.forEach((dispose) => dispose())) + + return output +} diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 5aa1e8553cb2..b53e618deb59 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -97,8 +97,9 @@ export type SessionPreviewProps = { activity?: { subagents: SessionActivityItem[] shells: SessionActivityItem[] + shell?: ComponentProps["shell"] onOpen?: (item: SessionActivityItem) => void - onStop?: (item: SessionActivityItem) => void + onStop?: (item: SessionActivityItem) => Promise } todos?: { items: Todo[]; open?: boolean } timelineDetail?: TimelineDetail @@ -320,11 +321,15 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void ? props.activity.onOpen(item) : setState("activity", `Opened ${item.label ?? item.key}`) } - onStop={(item) => - props.activity?.onStop - ? props.activity.onStop(item) - : setState("activity", `Stopped ${item.label ?? item.key}`) - } + shell={props.activity?.shell ?? storyShell} + onStop={(item) => { + if (props.activity?.onStop) return props.activity.onStop(item) + setState("activity", `Stopped ${item.label ?? item.key}`) + + return Promise.resolve(true) + }} + // The story's only editor is its composer. + onEmpty={() => document.querySelector('[contenteditable="true"]')?.focus()} /> {(todos) => } @@ -398,3 +403,18 @@ function SessionSurfaceHeader(props: { ) } + +// Background shells in stories print a fixed transcript and report a clean exit once they leave the list. +const storyShell: ComponentProps["shell"] = { + directory: "/story", + output: (input) => { + const output = `$ ${input.id}\nReady.\n` + const cursor = input.cursor ?? 0 + + return Promise.resolve({ + location: { directory: "/story" }, + data: { output: output.slice(cursor), cursor: output.length, size: output.length, truncated: false }, + }) + }, + ended: () => Promise.resolve({ status: "exited", exit: 0 }), +} diff --git a/packages/app/test-browser/shell-panel.test.ts b/packages/app/test-browser/shell-panel.test.ts new file mode 100644 index 000000000000..d20c620e68f7 --- /dev/null +++ b/packages/app/test-browser/shell-panel.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, setSystemTime, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import type { SessionActivityItem } from "@/session/composer/activity-chips" +import type { ShellOutputInput, ShellOutputOutput } from "@opencode/client/promise" +import { createShellPanel, createShellTail } from "@/session/composer/shell-panel" + +const server: SessionActivityItem = { + key: "sh_dev", + type: "shell", + label: "bun run dev", + target: "sh_dev", + started: 1_000, +} +const watcher: SessionActivityItem = { + key: "sh_test", + type: "shell", + label: "bun test --watch", + target: "sh_test", + started: 2_000, +} + +type Ended = Awaited[0]["ended"]>> + +function deferred() { + let resolve = (_: T) => {} + const promise = new Promise((done) => (resolve = done)) + + return { promise, resolve } +} + +function setup() { + const [shells, setShells] = createSignal([server, watcher]) + const stops: { item: SessionActivityItem; result: ReturnType> }[] = [] + const ends: { id: string; result: ReturnType> }[] = [] + + return createRoot((dispose) => ({ + dispose, + setShells, + stops, + ends, + panel: createShellPanel({ + shells, + stop: (item) => { + const result = deferred() + stops.push({ item, result }) + + return result.promise + }, + ended: (id) => { + const result = deferred() + ends.push({ id, result }) + + return result.promise + }, + }), + })) +} + +afterEach(() => { + setSystemTime() +}) + +describe("createShellPanel", () => { + test("opens one shell from the list and goes back", () => { + const view = setup() + + expect(view.panel.state.shell).toBeUndefined() + view.panel.open(watcher) + expect(view.panel.state.shell?.key).toBe("sh_test") + expect(view.panel.state.end).toBeUndefined() + + view.panel.back() + expect(view.panel.state.shell).toBeUndefined() + view.dispose() + }) + + test("Stop ends the shell as stopped without asking the server how it ended", async () => { + const view = setup() + view.panel.open(server) + view.panel.stop() + view.panel.stop() + + expect(view.stops.map((stop) => stop.item.key)).toEqual(["sh_dev"]) + expect(view.panel.state.stopping).toBe(true) + + // The removal reaches the running list before the request settles. + view.setShells([watcher]) + setSystemTime(new Date(9_000)) + view.stops[0].result.resolve(true) + await Promise.resolve() + + expect(view.ends).toEqual([]) + expect(view.panel.state.stopping).toBe(false) + expect(view.panel.state.end).toEqual({ status: "killed", completed: 9_000 }) + expect(view.panel.state.shell?.key).toBe("sh_dev") + view.dispose() + }) + + test("a failed Stop leaves the shell running", async () => { + const view = setup() + view.panel.open(server) + view.panel.stop() + view.stops[0].result.resolve(false) + await Promise.resolve() + + expect(view.panel.state.stopping).toBe(false) + expect(view.panel.state.end).toBeUndefined() + view.dispose() + }) + + test("a shell that exits while shown keeps its panel with how it ended", async () => { + const view = setup() + view.panel.open(watcher) + view.setShells([server]) + + expect(view.ends.map((end) => end.id)).toEqual(["sh_test"]) + view.ends[0].result.resolve({ status: "exited", exit: 1, completed: 7_000 }) + await Promise.resolve() + + expect(view.panel.state.shell?.key).toBe("sh_test") + expect(view.panel.state.end).toEqual({ status: "exited", exit: 1, completed: 7_000 }) + view.dispose() + }) + + test("a shell the server no longer knows ended as stopped", async () => { + const view = setup() + view.panel.open(watcher) + setSystemTime(new Date(8_000)) + view.setShells([server]) + view.ends[0].result.resolve(undefined) + await Promise.resolve() + + expect(view.panel.state.end).toEqual({ status: "killed", exit: undefined, completed: 8_000 }) + view.dispose() + }) + + test("an exit that settles after going back does not reopen the panel", async () => { + const view = setup() + view.panel.open(watcher) + view.setShells([server]) + view.panel.back() + view.ends[0].result.resolve({ status: "exited", exit: 0, completed: 7_000 }) + await Promise.resolve() + + expect(view.panel.state.shell).toBeUndefined() + expect(view.panel.state.end).toBeUndefined() + view.dispose() + }) + + test("a shell that left the list asks once how it ended while the list keeps changing", () => { + const view = setup() + view.panel.open(watcher) + view.setShells([server]) + view.setShells([]) + view.setShells([server]) + + expect(view.ends.map((end) => end.id)).toEqual(["sh_test"]) + expect(view.panel.state.ending).toBe(true) + view.dispose() + }) + + test("Stop does nothing once the shell has left the list, so its real exit shows", async () => { + const view = setup() + view.panel.open(watcher) + view.setShells([server]) + view.panel.stop() + + expect(view.stops).toEqual([]) + view.ends[0].result.resolve({ status: "exited", exit: 1, completed: 7_000 }) + await Promise.resolve() + + expect(view.panel.state.end).toEqual({ status: "exited", exit: 1, completed: 7_000 }) + expect(view.panel.state.ending).toBe(false) + view.dispose() + }) +}) + +describe("createShellTail", () => { + test("a first read still in flight when the shell ends still lands", async () => { + const [running, setRunning] = createSignal(true) + const reads: { input: ShellOutputInput; result: ReturnType> }[] = [] + const view = createRoot((dispose) => ({ + dispose, + output: createShellTail({ + id: () => "sh_tail_inflight", + directory: () => "/repo", + running, + load: (input) => { + const result = deferred() + reads.push({ input, result }) + + return result.promise + }, + }), + })) + + expect(reads.map((read) => read.input.cursor)).toEqual([0]) + setRunning(false) + // The final snapshot starts over, but Stop already deleted the output. + expect(reads.map((read) => read.input.cursor)).toEqual([0, 0]) + reads[0].result.resolve({ + location: { directory: "/repo" }, + data: { output: "$ bun test\nReady.\n", cursor: 18, size: 18, truncated: false }, + }) + await Promise.resolve() + await Promise.resolve() + + expect(view.output()).toBe("$ bun test\nReady.\n") + view.dispose() + }) +}) diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index a104a518d3a2..8aaab9776153 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -45,6 +45,8 @@ "./sources/title": "./src/sources/sources-title.tsx", "./styles": "./src/styles/index.css", "./storybook": "./src/storybook/index.ts", + "./tools/ansi": "./src/tools/ansi.ts", + "./tools/shell-output": "./src/tools/shell-output.ts", "./v2/*.css": "./src/v2/components/*.css", "./v2/*": "./src/v2/components/*.tsx" }, From cf4f4d6e3e4c340615f07728677a0cfa390c2bb0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 28 Oct 2026 10:49:06 -0700 Subject: [PATCH 2/3] test(app): update background-shortcut e2e for the shell panel Clicking a shell in the activity pill now opens its output in the menu instead of revealing its call in the timeline, so four e2e tests expected the old behaviour: - "separates blocking and already-backgrounded work" now checks that the shell row opens the panel, goes Back, and opens the Used group itself before checking the shell call's shimmer. - "holds a revealed shell ... under its stuck group header" (3 cases) now reveal a starting subagent call, the pill row that still reveals its call, with subagents grouped so the call sits in the collapsed group. They keep covering revealPart's group expansion and stuck-header hold. Revealing a call by shell ID stays covered by "jumps from a finished shell's notice to its call". --- .../e2e/regression/timeline-stream.spec.ts | 86 +++++++------------ packages/app/src/session/composer/region.tsx | 2 +- 2 files changed, 30 insertions(+), 58 deletions(-) diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index adb953355800..e1812ce1c64f 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -1352,14 +1352,11 @@ test.describe("background shortcut", () => { /^Explore\s*Background task\d+h \d{2}m$/, /^sleep 120\d+h \d{2}m$/, ]) + // A shell opens its output in the menu rather than revealing its call in the timeline. await list.getByRole("menuitem", { name: /sleep 120/ }).click() - await expect(list).toHaveCount(0) - await expect(used).toHaveAttribute("aria-expanded", "true") - const shellCall = page.locator('[data-timeline-part-id="call_shell_backgrounded"]') - await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - await expect(shellCall).toBeInViewport() - - await activity.click() + await expect(list).toHaveAttribute("data-view", "shell") + await expect(used).toHaveAttribute("aria-expanded", "false") + await list.getByRole("menuitem", { name: "Back to background work", exact: true }).click() const interrupt = page.waitForRequest( (request) => @@ -1384,6 +1381,7 @@ test.describe("background shortcut", () => { await expect(list).toHaveCount(0) await expect(backgroundCard).toContainText("Background task (background)") await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible() + await used.click() await expect( page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'), ).toHaveAttribute("data-active", "true") @@ -1407,9 +1405,10 @@ test.describe("background shortcut", () => { // Reaching the shell lands on the end of the timeline, which would otherwise follow new content again. { name: "at the end of the timeline", before: 40, searches: 0, after: 0 }, ]) { - test(`holds a revealed shell ${fixture.name} just under its stuck group header`, async ({ page }) => { + // A starting subagent call is the pill's row that reveals its call; grouping subagents puts it inside the group. + test(`holds a revealed call ${fixture.name} just under its stuck group header`, async ({ page }) => { const timeline = await setupTimeline(page, { - settings: { timelineDetail: detailed }, + settings: { timelineDetail: { ...detailed, subagents: { placement: "grouped" } } }, sessionStatus: { [sessionID]: { type: "busy" } }, messages: [ userMessage(), @@ -1417,14 +1416,8 @@ test.describe("background shortcut", () => { [ ...(fixture.before ? [textPart("prt_earlier", notes("Earlier", fixture.before))] : []), ...searches(0, 42 - fixture.searches), - toolPart( - "prt_far_shell", - "shell", - "completed", - { command: "sleep 120" }, - { output: "working", metadata: { shellID: "shell_far", status: "running" } }, - ), - // The open group outgrows the viewport, so aligning the group instead of the shell misses it. + toolPart("prt_far_call", "subagent", "running", { description: "Map the far code", agent: "explore" }), + // The open group outgrows the viewport, so aligning the group instead of the call misses it. ...searches(42 - fixture.searches, fixture.searches), ...(fixture.after ? [textPart("prt_follow_up", notes("Follow-up", fixture.after))] : []), ], @@ -1433,60 +1426,40 @@ test.describe("background shortcut", () => { ], }) - await timeline.transport.send({ - id: "evt_far_shell_created", - created: 3, - type: "shell.created", - location: { directory }, - data: { - info: { - id: "shell_far", - status: "running", - command: "sleep 120", - cwd: directory, - shell: "bash", - file: "/tmp/far.out", - metadata: { sessionID }, - time: { started: 2 }, - }, - }, - }) - const group = page .locator('[data-component="collapsed-tool-group"]') .locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]') - const shellTrigger = page.locator('[data-timeline-part-id="prt_far_shell"] [data-slot="collapsible-trigger"]') + const call = page.locator('[data-timeline-part-id="prt_far_call"]') const aligned = () => expect .poll(async () => { - const [header, shell] = await Promise.all([group.boundingBox(), shellTrigger.boundingBox()]) + const [header, row] = await Promise.all([group.boundingBox(), call.boundingBox()]) - return header && shell ? Math.round(shell.y - (header.y + header.height)) : undefined + return header && row ? Math.round(row.y - (header.y + header.height)) : undefined }) .toBe(0) await expect( fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group, ).toBeInViewport() - await expect(shellTrigger).toHaveCount(0) - await page.getByRole("button", { name: "1 shell", exact: true }).click() + await expect(call).toHaveCount(0) + await page.getByRole("button", { name: "1 subagent", exact: true }).click() await page - .getByRole("menu", { name: "1 shell", exact: true }) - .getByRole("menuitem", { name: /sleep 120/ }) + .getByRole("menu", { name: "1 subagent", exact: true }) + .getByRole("menuitem", { name: /Map the far code/ }) .click() await expect(group).toHaveAttribute("aria-expanded", "true") - await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") - await expect(shellTrigger).toBeInViewport() + await expect(call).toBeInViewport() - // A shell at the very end cannot rise under the header until more content arrives below it. + // A call at the very end cannot rise under the header until more content arrives below it. if (fixture.searches) await aligned() // Nothing covers the row, so it takes the next click. - await shellTrigger.click({ trial: true }) + await call.click({ trial: true }) - // The agent keeps writing below the shell; the shell stays where it was revealed. + // The agent keeps writing below the call; the call stays where it was revealed. await timeline.send(partUpdated(textPart("prt_late", notes("Late", 30)))) await expect(page.getByText("Late note 30.", { exact: true })).toBeAttached() await aligned() @@ -2142,10 +2115,7 @@ test("highlights a quote's source only while its first comment is written", asyn await expect .poll(() => first.evaluate((element) => - [ - "::selection", - "::highlight(timeline-quote-source)", - ].map((pseudo) => { + ["::selection", "::highlight(timeline-quote-source)"].map((pseudo) => { const style = getComputedStyle(element, pseudo) return { @@ -2245,11 +2215,13 @@ test("keeps the native selection height after quoting wrapped and formatted text const height = await source.locator("p").evaluate((element) => parseFloat(getComputedStyle(element).lineHeight)) await expect .poll(() => - source.locator('[data-component="timeline-quote-background"] > span').evaluateAll( - (lines, height) => - lines.length > 0 && lines.every((line) => Math.abs(line.getBoundingClientRect().height - height) < 0.1), - height, - ), + source + .locator('[data-component="timeline-quote-background"] > span') + .evaluateAll( + (lines, height) => + lines.length > 0 && lines.every((line) => Math.abs(line.getBoundingClientRect().height - height) < 0.1), + height, + ), ) .toBe(true) }) diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 25477dc72263..d46c3e309ef4 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -463,7 +463,7 @@ export function ActiveSessionComposerRegion(props: { if (item.sessionID) return openRoute(current, item.sessionID) - // Shells and starting subagent calls live in this timeline. + // A starting subagent call has no session yet, so it lives in this timeline. props.onReveal(item.target) } From 3118dbe02ac0ae192b2d43d700890be7e6dfc09f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 28 Oct 2026 13:26:17 -0700 Subject: [PATCH 3/3] chore(app): fix lint spacing in the shell panel files --- .../app/src/session/composer/activity-chips.stories.tsx | 1 + packages/app/src/session/composer/activity-chips.tsx | 5 +++++ packages/app/src/session/composer/shell-panel.ts | 6 ++++++ packages/app/test-browser/shell-panel.test.ts | 3 +++ 4 files changed, 15 insertions(+) diff --git a/packages/app/src/session/composer/activity-chips.stories.tsx b/packages/app/src/session/composer/activity-chips.stories.tsx index 0100a4f12717..902b5ee7c10a 100644 --- a/packages/app/src/session/composer/activity-chips.stories.tsx +++ b/packages/app/src/session/composer/activity-chips.stories.tsx @@ -65,6 +65,7 @@ type PreviewArgs = { stage: number; subagents: number; shells: number } function Preview(props: PreviewArgs) { const value = createMemo(() => liveDocument(stages[props.stage] ?? stages[0])) // Built once per count, so each item keeps the start time it had when it appeared. + const activity = createMemo(() => ({ subagents: Array.from({ length: props.subagents }, (_, index) => subagentItem(index)), shells: Array.from({ length: props.shells }, (_, index) => shellItem(index)), diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index 9c5bfa0ff65d..4d830826f8be 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -114,10 +114,12 @@ export function SessionActivityChips(props: { // Focus moves a frame late: the menu refocuses itself when the selected row unmounts. One pending move at a time. let frame = 0 + const later = (move: () => void) => { cancelAnimationFrame(frame) frame = requestAnimationFrame(move) } + onCleanup(() => cancelAnimationFrame(frame)) // Opening a shell moves focus to its back arrow. @@ -368,7 +370,9 @@ export function SessionActivityChips(props: { function endLabel(end: ShellEnd, t: ReturnType["t"]) { if (end.status === "timeout") return t("session.activity.shell.timeout") + if (end.status === "killed") return t("session.activity.shell.killed") + if (end.exit === undefined) return t("session.activity.shell.exited") return t("session.activity.shell.exitCode", { code: String(end.exit) }) @@ -385,6 +389,7 @@ function ShellTail(props: { load: (input: ShellOutputInput) => Promise }) { const language = useLanguage() + const output = createShellTail({ id: () => props.id, directory: () => props.directory, diff --git a/packages/app/src/session/composer/shell-panel.ts b/packages/app/src/session/composer/shell-panel.ts index 14276d448b32..89277d0af64f 100644 --- a/packages/app/src/session/composer/shell-panel.ts +++ b/packages/app/src/session/composer/shell-panel.ts @@ -69,6 +69,7 @@ export function createShellPanel(input: { setState("stopping", true) void input.stop(item).then((stopped) => { if (!shown(item.key)) return + if (stopped && !state.end) setState("end", { status: "killed", completed: Date.now() }) setState("stopping", false) }) @@ -91,10 +92,12 @@ export function createShellTail(input: { const id = input.id() let loaded = false let retired = false + const dispose = () => { retiring.delete(dispose) stop() } + const stop = followShellOutput({ id, directory: input.directory(), @@ -102,13 +105,16 @@ export function createShellTail(input: { load: input.load, onOutput: (text) => { loaded = true + if (!retired || input.id() === id) setOutput(text) + if (retired) dispose() }, }) onCleanup(() => { retired = true + if (loaded) return dispose() retiring.add(dispose) }) diff --git a/packages/app/test-browser/shell-panel.test.ts b/packages/app/test-browser/shell-panel.test.ts index d20c620e68f7..340365401d80 100644 --- a/packages/app/test-browser/shell-panel.test.ts +++ b/packages/app/test-browser/shell-panel.test.ts @@ -11,6 +11,7 @@ const server: SessionActivityItem = { target: "sh_dev", started: 1_000, } + const watcher: SessionActivityItem = { key: "sh_test", type: "shell", @@ -23,6 +24,7 @@ type Ended = Awaited[0]["ended"]> function deferred() { let resolve = (_: T) => {} + const promise = new Promise((done) => (resolve = done)) return { promise, resolve } @@ -179,6 +181,7 @@ describe("createShellTail", () => { test("a first read still in flight when the shell ends still lands", async () => { const [running, setRunning] = createSignal(true) const reads: { input: ShellOutputInput; result: ReturnType> }[] = [] + const view = createRoot((dispose) => ({ dispose, output: createShellTail({