From 6fe81cc1900b54f214db06ad55901b0151a11351 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 18 Oct 2026 23:34:22 -0400 Subject: [PATCH] refactor(app): move running work controls into the activity capsule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../e2e/regression/timeline-stream.spec.ts | 259 +-------------- packages/app/src/runtime/i18n/en.ts | 8 - .../src/session/composer/activity-chips.css | 20 ++ .../src/session/composer/activity-chips.tsx | 57 +++- packages/app/src/session/composer/region.tsx | 28 +- .../session/header/session-running-menu.tsx | 312 ------------------ packages/app/src/session/requests/model.ts | 12 +- packages/app/src/session/screen.tsx | 19 +- .../src/session/session-identity-header.tsx | 10 +- packages/app/src/session/story-model.tsx | 6 + .../app/src/session/timeline/interaction.ts | 6 + .../src/session/timeline/message-timeline.tsx | 36 +- .../app/src/session/timeline/virtualizer.tsx | 3 + 13 files changed, 150 insertions(+), 626 deletions(-) delete mode 100644 packages/app/src/session/header/session-running-menu.tsx diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index a927f8dfc3f..929fe4ae893 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -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() - const release = Promise.withResolvers() - - 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() }) }) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index b9ae2d79b42..ad84925d907 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/composer/activity-chips.css b/packages/app/src/session/composer/activity-chips.css index d4ab49a61e5..6391d37b8ce 100644 --- a/packages/app/src/session/composer/activity-chips.css +++ b/packages/app/src/session/composer/activity-chips.css @@ -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; diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index da7e87664a0..62848f64203 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -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) => ( + + {/* The row selects on press, so the stop button keeps its pointer events to itself. */} + } + 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) + }} + /> + + ) + const label = () => language.activitySummary({ subagents: props.subagents.length, shells: props.shells.length }) return ( @@ -48,16 +88,21 @@ export function SessionActivityChips(props: { {(item) => ( - props.onOpen(item)}> + {item.agent ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` : language.t("ui.tool.agent.default")} - + {item.label} + {stop(item)} )} @@ -73,14 +118,12 @@ export function SessionActivityChips(props: { {(item) => ( - props.onOpen(item)}> + - + {item.label} + {stop(item)} )} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 8aefbe4a650..e6236cca32e 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -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={
- +
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(() => [ - ...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 ( - -
{ - 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) - }} - > - - - - setMenu("open", open)} - > - - - - } - > -

- - - - {props.title} - - - 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) - }} - > - - - - -

-
- - - - {(item) => ( - { - // 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) - }} - > - } - > - {/* Built-in agents have theme tokens; any other agent keeps the neutral color. */} - - - - {item.type === "shell" - ? language.t("ui.tool.shell") - : item.agent - ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` - : language.t("ui.tool.agent.default")} - - {/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */} - - {item.label} - - - {/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */} - - - - )} - - - -
-
-
- ) -} diff --git a/packages/app/src/session/requests/model.ts b/packages/app/src/session/requests/model.ts index a1befe2333b..cc230d1d607 100644 --- a/packages/app/src/session/requests/model.ts +++ b/packages/app/src/session/requests/model.ts @@ -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, diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 4e0993781c5..63ee5d4e1d6 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -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: { - - - + {timelineView()} @@ -321,7 +313,12 @@ function SessionScreenContent(props: { {(model) => ( - + )} {mobileTabs()} diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 8ef4a987d8b..1d726f3b4a7 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -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 /> )} - {props.children} - - - - + + {(value) => (

)} - {props.children}

diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 892ba29661b..6c2934d5722 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -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}`) + } />
diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index 7e1d261bdc9..0a7f62693ca 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -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), diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index f9979a45b56..9e22d296098 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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 tasks: Accessor - running: { - sessionID: Accessor - blocking: Accessor - tasks: Accessor - } move: () => Promise } @@ -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( ) - const runningMenu = (title?: string) => ( - - ) - return ( )} - {runningMenu(childTitle())} + + {(value) => ( + <> + +

+ {value()} +

+ + )} +
- {runningMenu()} {(id) => ( >, ) => 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?.(() => {}) } })