refactor(app): move running work controls into the activity capsule

Remove the "N running" indicator and menu from the session header; the
composer's activity capsule already lists running subagents and shells.
Subagent sessions now show a plain title with the working indicator.

Each capsule panel row gets a trailing × that interrupts the subagent or
kills the shell (also Delete/Backspace on the highlighted row), reusing the
existing stop strings and requests. Picking a shell or a starting subagent
call now reveals it in the current timeline instead of re-navigating to the
same route, which never opened it.

Drop the now-unused session.running count strings and the parent-owned
background state that only the header menu read.
This commit is contained in:
Dax Raad
2026-10-18 23:34:22 -04:00
Unverified
parent 161cbad85b
commit 6fe81cc190
13 changed files with 150 additions and 626 deletions
@@ -957,12 +957,14 @@ test.describe("background shortcut", () => {
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await page.getByRole("button", { name: "3 running", exact: true }).click()
const list = page.getByRole("menu", { name: "3 running", exact: true })
const activity = page.getByRole("button", { name: "2 subagents · 1 shell", exact: true })
const list = page.getByRole("menu", { name: "2 subagents · 1 shell", exact: true })
await activity.click()
await expect(list.getByRole("menuitem")).toHaveText([
/^Build\s*Foreground task$/,
/^Explore\s*Background task$/,
/^Shell\s*sleep 120$/,
/^sleep 120$/,
])
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
await expect(list).toHaveCount(0)
@@ -971,7 +973,7 @@ test.describe("background shortcut", () => {
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(shellCall).toBeInViewport()
await page.getByRole("button", { name: "3 running", exact: true }).click()
await activity.click()
const interrupt = page.waitForRequest(
(request) =>
@@ -1083,9 +1085,9 @@ test.describe("background shortcut", () => {
fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group,
).toBeInViewport()
await expect(shellTrigger).toHaveCount(0)
await page.getByRole("button", { name: "1 running", exact: true }).click()
await page.getByRole("button", { name: "1 shell", exact: true }).click()
await page
.getByRole("menu", { name: "1 running", exact: true })
.getByRole("menu", { name: "1 shell", exact: true })
.getByRole("menuitem", { name: /sleep 120/ })
.click()
@@ -1105,7 +1107,7 @@ test.describe("background shortcut", () => {
})
}
test("hides the running switcher when viewing the only running subagent", async ({ page }) => {
test("shows a running subagent's working indicator beside its title", async ({ page }) => {
const childID = "ses_only_running_child"
const timeline = await setupTimeline(page, {
@@ -1118,13 +1120,11 @@ test.describe("background shortcut", () => {
})
const header = page.locator("[data-session-title]")
const trigger = header.getByRole("button", { name: "1 working", exact: true })
await trigger.click()
await page.getByRole("button", { name: "1 subagent", exact: true }).click()
await page.getByRole("menuitem", { name: /Only running task/ }).click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(header.getByRole("heading")).toHaveText("Only running task")
await expect(trigger).toHaveCount(0)
const working = header.getByRole("status", { name: "Working", exact: true })
@@ -1140,7 +1140,7 @@ test.describe("background shortcut", () => {
expect(gap).toBe(8)
const title = header.getByRole("heading").locator('span[dir="auto"]')
const title = header.getByRole("heading")
const before = await title.boundingBox()
const exit = await pauseExitAnimations(working)
@@ -1162,243 +1162,6 @@ test.describe("background shortcut", () => {
await exit.dispose()
await header.locator('[data-slot="session-title-parent"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
await expect(trigger).toHaveCount(0)
})
test("keeps the running menu closed when work restarts after all subagents finish", async ({ page }) => {
const childID = "ses_restarted_child"
const otherID = "ses_counted_child"
const timeline = await setupTimeline(page, {
sessionMessages: [user, completed],
sessions: [
session(),
session({ id: childID, parentID: sessionID, title: "Restarted task", agent: "explore" }),
session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }),
],
sessionStatus: {},
})
const trigger = page.locator("[data-session-title]").getByRole("button", { name: /^[12] working$/ })
const menu = page.getByRole("menu", { name: "1 working", exact: true })
await timeline.transport.send(status("busy", 1, childID))
await expect(trigger).toHaveAccessibleName("1 working")
await timeline.transport.send(status("busy", 1, otherID))
await expect(trigger).toHaveAccessibleName("2 working")
await expect(trigger.locator("..")).toHaveCSS("animation-name", "none")
await timeline.transport.send(status("idle", 1, otherID))
await expect(trigger).toHaveAccessibleName("1 working")
// Inspect the update itself, not the settled state after a replayed fade finishes.
expect(await trigger.evaluate((element) => getComputedStyle(element.parentElement!).animationName)).toBe("none")
await trigger.click()
await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible()
await timeline.transport.send(status("idle", 1, childID))
await expect(trigger).toHaveCount(0)
await expect(menu).toHaveCount(0)
await timeline.transport.send(status("busy", 1, childID))
await expect(trigger).toBeEnabled()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(menu).toHaveCount(0)
await trigger.click()
await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible()
})
test("keeps the subagent title anchored when its additional count disappears", async ({ page }) => {
const childID = "ses_collapsing_viewed"
const otherID = "ses_collapsing_sibling"
const timeline = await setupTimeline(page, {
sessionMessages: [user, completed],
sessions: [
session(),
session({ id: childID, parentID: sessionID, title: "Viewed task", agent: "explore" }),
session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }),
],
sessionStatus: { [childID]: { type: "busy" }, [otherID]: { type: "busy" } },
})
const header = page.locator("[data-session-title]")
await header.getByRole("button", { name: "2 working", exact: true }).click()
await page.getByRole("menuitem", { name: /Viewed task/ }).click()
await expect(header.getByRole("heading")).toHaveAccessibleName("Viewed task")
const trigger = header.getByRole("button", { name: "Viewed task +1 working", exact: true })
const title = header.getByRole("heading").locator('span[dir="auto"]')
await expect(trigger).toBeEnabled()
const before = await title.boundingBox()
const exit = await pauseExitAnimations(trigger.locator('[data-component="text-shimmer"]').locator(".."))
await timeline.transport.send(status("idle", 1, otherID))
await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0)
await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 75)))
const midway = await title.boundingBox()
expect(midway!.x).toBeCloseTo(before!.x, 0)
await exit.evaluate((animations) => animations.forEach((animation) => animation.finish()))
await expect(trigger).toHaveCount(0)
const after = await title.boundingBox()
expect(after!.x).toBeCloseTo(before!.x, 0)
await expect(header.getByRole("button", { name: "Viewed task", exact: true })).toBeDisabled()
await exit.dispose()
})
test("moves between siblings and to the parent's shell from inside a subagent", async ({ page }) => {
const siblings = [
{ id: "ses_sibling_one", agent: "explore", description: "Draft TUI proposal" },
{ id: "ses_sibling_two", agent: "build", description: "Fix context controls" },
]
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionMessages: [
user,
{
id: "msg_sibling_shell",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_sibling_shell",
name: "shell",
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "working" }],
metadata: { shellID: "shell_sibling", status: "running" },
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
{
id: "msg_siblings",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: siblings.map((item) => ({
type: "tool" as const,
id: `call_${item.id}`,
name: "subagent",
state: {
status: "running" as const,
input: { description: item.description, agent: item.agent },
metadata: { sessionID: item.id },
},
time: { created: 2 },
})),
time: { created: 2 },
},
],
sessions: [
session(),
...siblings.map((item) =>
session({ id: item.id, parentID: sessionID, title: item.description, agent: item.agent }),
),
],
sessionStatus: Object.fromEntries(
[sessionID, ...siblings.map((item) => item.id)].map((id) => [id, { type: "busy" }]),
),
})
await timeline.transport.send({
id: "evt_sibling_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_sibling",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/sibling.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
const header = page.locator("[data-session-title]")
const trigger = header.getByRole("button", { name: /(?:^3 running$|\+2 running$)/ })
const list = page.getByRole("menu", { name: /(?:^3 running$|\+2 running$)/ })
await trigger.click()
await list.getByRole("menuitem", { name: /Draft TUI proposal/ }).click()
await expect(page).toHaveURL(/\/session\/ses_sibling_one$/)
await expect(list).toHaveCount(0)
await expect(header.getByRole("heading")).toHaveAccessibleName("Draft TUI proposal")
await expect(trigger.locator('[data-component="text-shimmer"]')).toHaveCSS("font-variant-numeric", "tabular-nums")
const countGap = await trigger.evaluate((element) => {
const title = document.createRange()
title.selectNodeContents(element.querySelector('span[dir="auto"]')!)
return (
element.querySelector('[data-component="text-shimmer"]')!.getBoundingClientRect().left -
title.getBoundingClientRect().right
)
})
expect(countGap).toBe(8)
const breadcrumbOrder = await header
.locator('[data-slot="session-title-parent"], [data-slot="session-title-child"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-slot")))
expect(breadcrumbOrder).toEqual(["session-title-parent", "session-title-child"])
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveCount(1)
await expect(header.getByRole("heading").getByRole("button", { name: "Draft TUI proposal +2 running" })).toBeEnabled()
await trigger.click()
await expect(list.getByRole("menuitem")).toHaveText([
/^Explore\s*Draft TUI proposal/,
/^Build\s*Fix context controls/,
/^Shell\s*sleep 120/,
])
await expect(list.getByRole("menuitem", { name: /Draft TUI proposal/ })).toHaveAttribute("aria-current", "page")
await expect(list.getByRole("menuitem", { name: /Fix context controls/ })).not.toHaveAttribute("aria-current")
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/session/ses_sibling_two/message",
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await list.getByRole("menuitem", { name: /Fix context controls/ }).click()
await requested.promise
await Promise.all([
expect(page).toHaveURL(/\/session\/ses_sibling_two$/),
expect(header.getByRole("heading")).toHaveAccessibleName("Fix context controls"),
expect(trigger).toBeEnabled(),
]).finally(() => release.resolve())
await expect(list).toHaveCount(0)
// The shell call lives in the parent: the row opens it there, expanded.
await trigger.click()
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
const shellCall = page.locator('[data-timeline-part-id="call_sibling_shell"]')
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(shellCall).toBeInViewport()
})
})
-8
View File
@@ -536,14 +536,6 @@ export const dict = {
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
"session.timeline.working": "Working",
"session.running.working.one": "{{count}} working",
"session.running.working.other": "{{count}} working",
"session.running.running.one": "{{count}} running",
"session.running.running.other": "{{count}} running",
"session.running.additionalWorking.one": "+{{count}} working",
"session.running.additionalWorking.other": "+{{count}} working",
"session.running.additionalRunning.one": "+{{count}} running",
"session.running.additionalRunning.other": "+{{count}} running",
"session.running.stop.subagent": "Interrupt subagent",
"session.running.stop.shell": "Kill shell command",
@@ -94,6 +94,26 @@
height: 15px;
}
/* A trailing stop button ends 4px in, mirroring the glyph's inset at the start. */
[data-component="menu-v2-item"][data-stoppable] {
padding-inline-end: 4px;
}
/* Hidden rather than removed, so revealing it on hover does not resize the panel. */
[data-slot="session-activity-stop"] {
visibility: hidden;
}
[data-component="menu-v2-item"]:is(:hover, [data-highlighted]) [data-slot="session-activity-stop"] {
visibility: visible;
}
@media (hover: none) {
[data-slot="session-activity-stop"] {
visibility: visible;
}
}
[data-slot="menu-v2-group-label"] {
height: 24px;
padding-inline: 4px;
@@ -1,4 +1,5 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { For, Index, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
@@ -18,12 +19,51 @@ export function SessionActivityChips(props: {
subagents: readonly SessionActivityItem[]
shells: readonly SessionActivityItem[]
onOpen: (item: SessionActivityItem) => void
onStop: (item: SessionActivityItem) => void
}) {
const language = useLanguage()
// With one kind running, the capsule row already names it.
const both = () => props.subagents.length > 0 && props.shells.length > 0
// A subagent can only be interrupted once its child session exists.
const stoppable = (item: SessionActivityItem) => item.type === "shell" || !!item.sessionID
const row = (item: SessionActivityItem) => ({
"data-stoppable": stoppable(item) || undefined,
onSelect: () => props.onOpen(item),
onKeyDown: (event: KeyboardEvent) => {
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
event.preventDefault()
props.onStop(item)
},
})
const stop = (item: SessionActivityItem) => (
<Show when={stoppable(item)}>
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
<IconButton
type="button"
size="small"
variant="ghost-muted"
tabIndex={-1}
data-slot="session-activity-stop"
icon={<Icon name="outline-xmark" />}
aria-label={language.t(item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent")}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerUp={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
props.onStop(item)
}}
/>
</Show>
)
const label = () => language.activitySummary({ subagents: props.subagents.length, shells: props.shells.length })
return (
@@ -48,16 +88,21 @@ export function SessionActivityChips(props: {
</Show>
<For each={props.subagents}>
{(item) => (
<Menu.Item onSelect={() => props.onOpen(item)}>
<Menu.Item {...row(item)}>
<SessionActivityGlyph class="text-v2-icon-icon-muted" />
<span class="shrink-0 font-[530]">
{item.agent
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
: language.t("ui.tool.agent.default")}
</span>
<span dir="auto" class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted">
<span
dir="auto"
class="min-w-0 flex-1 truncate text-v2-text-text-muted"
classList={{ "me-1.5": !stoppable(item) }}
>
{item.label}
</span>
{stop(item)}
</Menu.Item>
)}
</For>
@@ -73,14 +118,12 @@ export function SessionActivityChips(props: {
</Show>
<For each={props.shells}>
{(item) => (
<Menu.Item onSelect={() => props.onOpen(item)}>
<Menu.Item {...row(item)}>
<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />
<span
dir="ltr"
class="me-1.5 min-w-0 flex-1 truncate font-mono text-[12px] text-v2-text-text-base"
>
<span dir="ltr" class="min-w-0 flex-1 truncate font-mono text-[12px] text-v2-text-text-base">
{item.label}
</span>
{stop(item)}
</Menu.Item>
)}
</For>
+25 -3
View File
@@ -15,6 +15,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { showToast } from "@/shell/notifications/toast"
import { errorMessage } from "@/shell/layout/helpers"
import { useLocal } from "@/providers/models/selection"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -295,9 +296,12 @@ export function ActiveSessionComposerRegion(props: {
session: SessionModel
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
onReveal: (target: string) => void
}) {
const location = useWorkspaceLocation()
const data = useData()
const server = useServerSDK()
const language = useLanguage()
const openRoute = useOpenSessionRoute()
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
@@ -351,8 +355,26 @@ export function ActiveSessionComposerRegion(props: {
if (!current) return
// Shells and starting subagent calls live in this timeline, which reveals them once opened.
openRoute(current, item.sessionID ?? current, item.sessionID ? undefined : item.target)
if (item.sessionID) return openRoute(current, item.sessionID)
// Shells and starting subagent calls live in this timeline.
props.onReveal(item.target)
}
const stop = (item: SessionActivityItem) => {
const request = item.sessionID
? server.api.session.interrupt({ sessionID: item.sessionID })
: server.api.shell.remove({
id: item.target,
location: { directory: data.shell.get(item.target)?.location.directory },
})
void request.catch((error) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(error, language.t("common.requestFailed")),
}),
)
}
return (
@@ -364,7 +386,7 @@ export function ActiveSessionComposerRegion(props: {
keyed
fallback={
<div class="relative">
<SessionActivityChips subagents={subagents()} shells={shells()} onOpen={open} />
<SessionActivityChips subagents={subagents()} shells={shells()} onOpen={open} onStop={stop} />
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
@@ -1,312 +0,0 @@
import type { BackgroundTask } from "@opencode/gui-extensions/sdk"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { createEffect, createMemo, createUniqueId, For, on, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
import { useOpenSessionRoute } from "@/session/session-identity-header"
import { errorMessage } from "@/shell/layout/helpers"
import { showToast } from "@/shell/notifications/toast"
import { SessionWorkingIndicator } from "./session-working-indicator"
const neutral = "light-dark(var(--v2-text-text-base), #ffffff)"
type RunningItem = {
key: string
type: "subagent" | "shell"
label?: string
agent?: string
sessionID?: string
target: string
}
export function SessionRunningMenu(props: {
sessionID?: string
// The session whose running work is listed: this one, or its parent inside a subagent.
owner?: string
blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]
tasks: readonly BackgroundTask[]
onReveal?: (target: string) => void
title?: string
}) {
const language = useLanguage()
const openRoute = useOpenSessionRoute()
const server = useServer()
const sdk = useServerSDK()
const id = createUniqueId()
const [menu, setMenu] = createStore({ open: false })
const [appearance, setAppearance] = createStore<{
group?: HTMLElement
entered: boolean
}>({ entered: false })
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
// Foreground shells stay out: the timeline already shows them at the bottom.
const items = createMemo<RunningItem[]>(() => [
...props.blocking.flatMap((task) =>
task.type === "subagent"
? [
{
...task,
key: task.id ?? task.partID,
agent: sessionAgent(task.id),
sessionID: task.id,
target: task.partID,
},
]
: [],
),
...props.tasks.flatMap((task) =>
task.type === "subagent"
? [{ ...task, key: task.id, agent: task.agent ?? sessionAgent(task.id), sessionID: task.id, target: task.id }]
: [],
),
...props.tasks.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
])
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
const label = createMemo(() => {
const count = props.title ? items().filter((item) => !viewing(item)).length : items().length
if (!count || (!props.title && count === 1 && viewing(items()[0]))) return undefined
if (items().some((item) => item.type === "shell")) {
return language.plural(props.title ? "session.running.additionalRunning" : "session.running.running", count)
}
return language.plural(props.title ? "session.running.additionalWorking" : "session.running.working", count)
})
const presence = createAnimatedPresence(
label,
() => appearance.group ?? null,
() => props.sessionID,
)
// Label updates change presence state without changing visibility.
const visible = createMemo(presence.show)
createEffect(
on(
visible,
(show) => {
if (show) {
setAppearance("entered", false)
return
}
setMenu("open", false)
},
{ defer: true },
),
)
const open = (item: RunningItem) => {
const current = props.sessionID
if (!current || viewing(item)) return
if (item.sessionID) return openRoute(current, item.sessionID)
// Shell calls and starting subagent calls live in the owner's timeline, which reveals them once open.
if (props.owner && props.owner !== current) return openRoute(current, props.owner, item.target)
if (props.onReveal) return props.onReveal(item.target)
openRoute(current, current, item.target)
}
// A subagent can only be interrupted once its child session exists.
const stoppable = (item: RunningItem) => item.type === "shell" || !!item.sessionID
const stop = (item: RunningItem) => {
const request = item.sessionID
? sdk.api.session.interrupt({ sessionID: item.sessionID })
: sdk.api.shell.remove({
id: item.target,
location: { directory: server.ctx.data.shell.get(item.target)?.location.directory },
})
void request.catch((error) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(error, language.t("common.requestFailed")),
}),
)
}
return (
<Show when={props.title || presence.present()}>
<div
ref={(element) => {
if (!props.title) setAppearance("group", element)
}}
inert={!props.title && !presence.show()}
class="flex min-w-0 shrink-0 items-center gap-0.5 duration-150 ease-out motion-reduce:animate-none"
classList={{
"max-w-[45%]": !!props.title,
"animate-in fade-in": !props.title && presence.animate() && presence.show() && !appearance.entered,
"animate-out fade-out fill-mode-forwards": !props.title && presence.animate() && !presence.show(),
}}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true)
}}
>
<Show when={!props.title}>
<span
aria-hidden="true"
class="shrink-0 ps-2 pe-1 text-[11px] font-medium leading-text-compact text-v2-text-text-faint"
>
/
</span>
</Show>
<Menu
gutter={6}
placement="bottom-start"
open={menu.open && presence.show()}
onOpenChange={(open) => setMenu("open", open)}
>
<Show
when={props.title}
fallback={
<Menu.Trigger
as="button"
type="button"
aria-label={presence.value()}
class="flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] tabular-nums whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover"
>
<TextShimmer text={presence.value() ?? ""} active={presence.show()} />
</Menu.Trigger>
}
>
<h1
data-slot="session-title-child"
aria-label={props.title}
class="min-w-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base"
>
<Menu.Trigger
as="button"
type="button"
disabled={!visible()}
aria-labelledby={`${id}-title${visible() ? ` ${id}-count` : ""}`}
class="flex h-7 max-w-full items-center gap-0.5 rounded-[6px] outline-none enabled:hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover"
>
<SessionWorkingIndicator sessionID={props.sessionID} />
<span id={`${id}-title`} dir="auto" class="min-w-0 truncate px-1">
{props.title}
</span>
<Show when={presence.present()}>
<span
id={`${id}-count`}
ref={(element) => setAppearance("group", element)}
class="shrink-0 ps-0.5 pe-1 tabular-nums text-v2-text-text-muted duration-150 ease-out motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show() && !appearance.entered,
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true)
}}
>
<TextShimmer text={presence.value() ?? ""} active={presence.show()} />
</span>
</Show>
</Menu.Trigger>
</h1>
</Show>
<Menu.Portal>
<Menu.Content class="w-60" aria-label={presence.value()}>
<For each={items()}>
{(item) => (
<Menu.Item
class="group/running-item"
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
aria-current={viewing(item) ? "page" : undefined}
onSelect={() => {
// Close before navigating: the router's transition would hold the close until this
// timeline detaches, and the menu would flash at the viewport origin.
setMenu("open", false)
open(item)
}}
onKeyDown={(event) => {
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
event.preventDefault()
stop(item)
}}
>
<Show
when={item.type === "subagent"}
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
>
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
<SessionProgressIndicatorV2
class="shrink-0"
style={{
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
}}
/>
</Show>
<span class="shrink-0 font-[530]">
{item.type === "shell"
? language.t("ui.tool.shell")
: item.agent
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
: language.t("ui.tool.agent.default")}
</span>
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
<span
dir="auto"
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
classList={{
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
stoppable(item),
}}
>
{item.label}
</span>
<Show when={stoppable(item)}>
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
<IconButton
type="button"
size="small"
variant="ghost-muted"
tabIndex={-1}
icon={<Icon name="outline-xmark" />}
aria-label={language.t(
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
)}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerUp={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
stop(item)
}}
/>
</span>
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</Show>
)
}
+1 -11
View File
@@ -92,17 +92,8 @@ export function createSessionRequestModel() {
return !!id && !data.session.get(id)?.parentID
}
// Inside a subagent, running work is the parent's, so the header lists its siblings.
const owner = createMemo(() => {
const id = params.id
if (!id) return
return data.session.get(id)?.parentID ?? id
})
const running = createSessionBackground({
sessionID: owner,
sessionID: () => params.id,
messages: data.session.message.list,
sessions: data.session.list,
status: data.session.status,
@@ -166,7 +157,6 @@ export function createSessionRequestModel() {
background: {
blocking,
tasks,
running: { sessionID: owner, blocking: running.blocking, tasks: running.tasks },
move: moveToBackground,
},
decide,
+8 -11
View File
@@ -36,7 +36,6 @@ import { TimelineSearchBar } from "./timeline/search-bar"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { SessionRunningMenu } from "./header/session-running-menu"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createTimelineCache } from "./timeline/cache"
@@ -221,6 +220,7 @@ function SessionScreenContent(props: {
setContentRef={timeline.view.setContentRef}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setRevealPart={timeline.view.setRevealPart}
reveal={timeline.view.reveal}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
@@ -304,15 +304,7 @@ function SessionScreenContent(props: {
</Match>
<Match when={session.identity.params.id}>
<Show when={isDesktop() && !messagesReady()}>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()}>
<SessionRunningMenu
sessionID={session.identity.params.id}
owner={composer.requests.background.running.sessionID()}
blocking={composer.requests.background.running.blocking()}
tasks={composer.requests.background.running.tasks()}
title={session.data.parentID() ? session.data.info()?.title : undefined}
/>
</SessionIdentityHeader>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
</Show>
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
</Match>
@@ -321,7 +313,12 @@ function SessionScreenContent(props: {
<Show when={composer.active()} keyed>
{(model) => (
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
<ActiveSessionComposerRegion
session={session}
model={model}
suggestionBoundary={timeline.scroller}
onReveal={timeline.actions.revealPart}
/>
)}
</Show>
<Show when={!isDesktop() && !!session.identity.params.id && bottomMobileTabs()}>{mobileTabs()}</Show>
@@ -310,7 +310,7 @@ export function useOpenSessionRoute() {
}
}
export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; session?: SessionInfo }>) {
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const language = useLanguage()
@@ -399,11 +399,8 @@ export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; se
/>
)}
</Show>
<Show when={parentID() && props.children}>{props.children}</Show>
<Show when={!parentID() || !props.children}>
<SessionWorkingIndicator sessionID={props.sessionID} />
</Show>
<Show when={(!parentID() || !props.children) && title()}>
<SessionWorkingIndicator sessionID={props.sessionID} />
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
@@ -415,7 +412,6 @@ export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; se
</h1>
)}
</Show>
<Show when={!parentID()}>{props.children}</Show>
</div>
</div>
</div>
+6
View File
@@ -90,6 +90,7 @@ export type SessionPreviewProps = {
subagents: SessionActivityItem[]
shells: SessionActivityItem[]
onOpen?: (item: SessionActivityItem) => void
onStop?: (item: SessionActivityItem) => void
}
timelineDetail?: TimelineDetail
}
@@ -294,6 +295,11 @@ 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}`)
}
/>
<div class="relative z-10">
<Composer model={prompt.controller} borderUnderlay />
@@ -59,6 +59,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
// The active timeline's navigation, registered by its virtualizer.
let revealMessage = (_id: string, _partID?: string) => {}
let revealPart = (_target: string) => false
let scrollToEnd = (_behavior?: ScrollBehavior) => {}
let scrollMark = 0
@@ -362,6 +364,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
actions: {
navigateMessage,
revealMessage: (id: string, partID?: string) => revealMessage(id, partID),
revealPart: (target: string) => revealPart(target),
resume,
setActiveMessage,
},
@@ -389,6 +392,9 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
revealMessage = reveal
},
setRevealPart: (reveal: (target: string) => boolean) => {
revealPart = reveal
},
// The tool call the route opened the session on, revealed once per history entry.
reveal: {
target: () => (revealed === location.key ? undefined : location.state?.reveal),
@@ -27,18 +27,13 @@ import { parseVoiceDelegation } from "./voice-delegation"
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import { SessionRunningMenu } from "@/session/header/session-running-menu"
import { SessionWorkingIndicator } from "@/session/header/session-working-indicator"
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
type SessionBackground = {
blocking: Accessor<BlockingTask[]>
tasks: Accessor<readonly BackgroundTask[]>
running: {
sessionID: Accessor<string | undefined>
blocking: Accessor<BlockingTask[]>
tasks: Accessor<readonly BackgroundTask[]>
}
move: () => Promise<void>
}
@@ -87,6 +82,7 @@ type MessageTimelineProps = {
setContentRef: (el: HTMLDivElement) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setRevealPart?: (fn: (target: string) => boolean) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
reveal?: { target: () => string | undefined; done: () => void }
search?: JSX.Element
@@ -232,6 +228,7 @@ function MessageTimelineView(
)
},
setRevealMessage: props.setRevealMessage,
setRevealPart: props.setRevealPart,
setScrollToEnd: props.setScrollToEnd,
reveal: props.reveal,
})
@@ -431,17 +428,6 @@ function MessageTimelineView(
</Show>
)
const runningMenu = (title?: string) => (
<SessionRunningMenu
sessionID={sessionID()}
owner={props.background.running.sessionID()}
blocking={props.background.running.blocking()}
tasks={props.background.running.tasks()}
onReveal={virtualized.revealPart}
title={title}
/>
)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
@@ -481,7 +467,20 @@ function MessageTimelineView(
/>
)}
</Show>
<Show when={parentID()}>{runningMenu(childTitle())}</Show>
<Show when={parentID() && childTitle()}>
{(value) => (
<>
<SessionWorkingIndicator sessionID={sessionID()} />
<h1
data-slot="session-title-child"
dir="auto"
class="max-w-[45%] shrink-0 w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
</>
)}
</Show>
<Show when={!parentID() && (childTitle() || title.editing)}>
<Show
when={title.editing}
@@ -532,7 +531,6 @@ function MessageTimelineView(
/>
</Show>
</Show>
<Show when={!parentID()}>{runningMenu()}</Show>
<Show when={!parentID() && sessionID()} keyed>
{(id) => (
<Menu
@@ -77,6 +77,7 @@ type Input = {
disclosure: Readonly<Record<string, boolean | undefined>>,
) => boolean
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setRevealPart?: (fn: (target: string) => boolean) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
/** A tool call the route asks this timeline to open on; `done` clears it once revealed. */
reveal?: { target: () => string | undefined; done: () => void }
@@ -512,6 +513,7 @@ export function createTimelineVirtualizer(input: Input) {
reportOffset?.(root.scrollTop, false)
settleColdBottom()
})
input.setRevealPart?.(revealPart)
input.setRevealMessage?.((id, partID) => {
if (!active()) return
@@ -946,6 +948,7 @@ export function createTimelineVirtualizer(input: Input) {
if (active()) {
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setRevealPart?.(() => false)
input.setScrollToEnd?.(() => {})
}
})