Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/app/component-tests/subagent-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { expect, story } from "../../storybook/playwright/story"

story("a subagent session shows its model where the composer would be", async ({ mount, page }) => {
story("a subagent session names the subagent and its task where the composer would be", async ({ mount, page }) => {
await mount("app-current-session-surface--child-session")

const bar = page.locator('[data-component="subagent-bar"]')
await expect(bar.locator('[data-slot="subagent-model"]')).toHaveText("Anthropic / Claude Opus 5.5")
await expect(bar).toContainText(/high/i)
await expect(bar).toContainText("Surveyor subagent")
await expect(bar.locator('[data-slot="subagent-title"]')).toHaveText("Trace why the sidebar flickers on resize")
await expect(bar).not.toContainText("Claude Opus 5.5")
await expect(page.locator('[data-component="composer"]')).toHaveCount(0)
})
2 changes: 2 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"session.child.backToParent": "Main session",
"session.child.agent": "{{agent}} subagent",
"session.child.subagent": "Subagent",
"session.child.status.running": "Running",
"session.child.status.succeeded": "Done",
"session.child.status.failed": "Failed",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/session/composer/region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ export function ActiveSessionComposerRegion(props: {
{(info) => (
<SubagentBar
agent={info().agent}
title={info().title}
model={subagentModel()}
status={subagentStatus()}
started={info().time.created}
Expand Down
143 changes: 87 additions & 56 deletions packages/app/src/session/composer/subagent-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import { createEffect, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { formatKeybind } from "@/shell/commands/command"
import { formatElapsed } from "./activity-chips"

export type SubagentStatus = "running" | "succeeded" | "failed" | "interrupted"

// A subagent session takes no prompts, so its composer slot shows what the run is: the composer's geometry with the
// agent, model and variant where the composer's chips sit, the run's state where the placeholder sits, and the way back
// where the send button sits.
// A subagent session takes no prompts, so its composer slot says what the run is on one line: which subagent, its
// task, how it is going, and the way back. The model can't be changed here, so it only shows in the task's tooltip.
export function SubagentBar(props: {
agent?: string
title?: string
model?: { provider?: string; name: string; variant?: string }
status?: SubagentStatus
started: number
Expand All @@ -22,6 +23,10 @@ export function SubagentBar(props: {
}) {
const language = useLanguage()
const [now, setNow] = createSignal(Date.now())
// A finished run opens at rest; only a change seen while open fades in.
const [changed, setChanged] = createSignal(false)

createEffect(on(() => props.status, () => setChanged(true), { defer: true }))

createEffect(() => {
if (props.status !== "running") return
Expand All @@ -31,68 +36,94 @@ export function SubagentBar(props: {
onCleanup(() => clearInterval(timer))
})

const agent = () =>
props.agent
? language.t("session.child.agent", { agent: `${props.agent[0].toUpperCase()}${props.agent.slice(1)}` })
: language.t("session.child.subagent")

return (
<div
data-component="subagent-bar"
data-status={props.status}
data-dock-border-underlay="true"
class="w-full overflow-clip rounded-xl bg-v2-background-bg-base text-[13px] font-[440] leading-5 tabular-nums [[data-color-scheme=dark]_&]:bg-v2-background-bg-layer-01"
class="@container flex h-11 w-full items-center gap-3 overflow-clip rounded-xl bg-v2-background-bg-base ps-4 pe-2 text-[13px] font-[440] leading-text-compact whitespace-nowrap tabular-nums [[data-color-scheme=dark]_&]:bg-v2-background-bg-layer-01"
>
<div class="min-h-[60px] px-4 pt-4 pb-2 text-v2-text-text-faint">
<Show when={props.status}>
{(status) => (
<>
{language.t(`session.child.status.${status()}`)}
{" · "}
{formatElapsed((props.ended ?? now()) - props.started, language.t)}
</>
)}
</Show>
</div>
<div class="flex h-11 items-center px-2">
<span class="flex size-7 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Switch>
<Match when={props.status === "running"}>
<SessionProgressIndicatorV2 />
</Match>
<Match when={props.status === "succeeded"}>
<Icon name="check" size="small" />
</Match>
<Match when={props.status === "failed"}>
<Icon name="circle-xmark" size="small" />
</Match>
<Match when={props.status === "interrupted"}>
<Icon name="outline-xmark" size="small" />
</Match>
</Switch>
</span>
<div class="ms-1 me-3 flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap text-v2-text-text-muted">
<Show when={props.agent}>
{(agent) => <span class="shrink-0 px-[11px] capitalize">{agent()}</span>}
</Show>
<Show when={props.model}>
{(model) => (
<>
<span data-slot="subagent-model" class="flex min-w-0 px-[11px]">
<Show when={model().provider}>
<span class="shrink-0 text-v2-text-text-faint">{model().provider}</span>
<span class="shrink-0 whitespace-pre text-v2-text-text-faint">{" / "}</span>
<Tooltip
placement="top"
class="min-w-0 rounded-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus"
triggerTabIndex={0}
inactive={!props.title && !props.model}
contentClass="max-w-80"
value={
<span class="flex flex-col items-start gap-1">
<Show when={props.title}>{(title) => <span dir="auto">{title()}</span>}</Show>
<Show when={props.model}>
{(model) => (
<span class="text-v2-text-text-muted">
{[model().provider, model().name].filter(Boolean).join(" / ")}
<Show when={model().variant !== "default" && model().variant}>
{(variant) => <span class="capitalize">{` · ${variant()}`}</span>}
</Show>
<span class="truncate">{model().name}</span>
</span>
<Show when={model().variant !== "default" && model().variant}>
<span class="shrink-0 px-[11px] capitalize">{model().variant}</span>
</Show>
</>
)}
</Show>
</span>
}
>
<span class="flex min-w-0 items-center gap-2">
<span class="shrink-0 font-[530] text-v2-text-text-base">
<bdi>{agent()}</bdi>
</span>
<Show when={props.title}>
{(title) => (
<span data-slot="subagent-title" dir="auto" class="min-w-0 truncate text-v2-text-text-muted">
{title()}
</span>
)}
</Show>
</div>
<Button type="button" variant="ghost-faint" size="small" class="gap-1.5 px-1.5" onClick={props.onBack}>
{language.t("session.child.backToParent")}
<span class="hidden sm:block">
<Keybind keys={[formatKeybind("esc", language.t)]} variant="neutral" />
</span>
</Tooltip>
<Show when={props.status} keyed>
{(status) => (
<span
data-slot="subagent-state"
class="flex shrink-0 items-center gap-1.5 text-v2-text-text-faint duration-150 ease-out [:root[data-motion=reduce]_&]:animate-none"
classList={{ "animate-in fade-in": changed() }}
>
<span class="flex size-4 items-center justify-center text-v2-icon-icon-muted">
<Switch>
<Match when={status === "running"}>
<SessionProgressIndicatorV2 />
</Match>
<Match when={status === "succeeded"}>
<Icon name="check" size="small" />
</Match>
<Match when={status === "failed"}>
<Icon name="circle-xmark" size="small" />
</Match>
<Match when={status === "interrupted"}>
<Icon name="outline-xmark" size="small" />
</Match>
</Switch>
</span>
<span class="hidden @sm:inline">{language.t(`session.child.status.${status}`)}</span>
<span class="hidden @sm:inline">·</span>
<bdi>{formatElapsed((props.ended ?? now()) - props.started, language.t)}</bdi>
</span>
</Button>
</div>
)}
</Show>
<Button
type="button"
variant="ghost-faint"
size="small"
class="ms-auto shrink-0 gap-1.5 px-1.5"
onClick={props.onBack}
>
{language.t("session.child.backToParent")}
<span class="hidden sm:block">
<Keybind keys={[formatKeybind("esc", language.t)]} variant="neutral" />
</span>
</Button>
</div>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/session/session-screen.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
subagentDocument,
terminalPassedDocument,
} from "@opencode/session-ui/storybook"
import type { SubagentStatus } from "./composer/subagent-bar"
import { SessionPreview } from "./story-model"

const description = "opencode · modular-session-ui"
Expand Down Expand Up @@ -203,16 +204,21 @@ export const ReviewPlusTerminal = {
}

export const ChildSession = {
render: () => (
args: { status: "running", title: "Trace why the sidebar flickers on resize", permission: false },
argTypes: { status: { control: "select", options: ["running", "succeeded", "failed", "interrupted"] } },
render: (args: { status: SubagentStatus; title: string; permission: boolean }) => (
<SessionPreview
title="Inspect the child Session"
description={description}
document={subagentDocument}
document={args.permission ? permissionPendingDocument : subagentDocument}
request={args.permission ? { type: "permission", value: activePermissionRequest } : undefined}
child={{
agent: "surveyor",
title: args.title,
model: { provider: "Anthropic", name: "Claude Opus 5.5", variant: "high" },
status: "running",
status: args.status,
started: Date.now() - 65_000,
ended: args.status === "running" ? undefined : Date.now() + 187_000,
}}
/>
),
Expand All @@ -226,6 +232,7 @@ export const ChildSessionDone = {
document={subagentDocument}
child={{
agent: "surveyor",
title: "Map every caller of the legacy theme tokens",
model: { provider: "Anthropic", name: "Claude Opus 5.5", variant: "high" },
status: "succeeded",
started: 0,
Expand Down
Loading