Skip to content

Commit d4ee5f0

Browse files
authored
fix(app): slash in the subagent breadcrumb and a quieter subagent bar (#59347)
1 parent ca7d60b commit d4ee5f0

7 files changed

Lines changed: 118 additions & 102 deletions

File tree

‎packages/app/component-tests/subagent-bar.spec.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,26 @@ story("a subagent session shows its model where the composer would be", async ({
44
await mount("app-current-session-surface--child-session")
55

66
const bar = page.locator('[data-component="subagent-bar"]')
7-
await expect(bar.locator('[data-slot="subagent-model"]')).toHaveText("Anthropic / Claude Opus 5.5")
8-
await expect(bar).toContainText(/high/i)
7+
// The variant follows the model name, dimmer and without a separator, like the message metadata.
8+
const model = bar.locator('[data-slot="subagent-model"]')
9+
await expect(model).toHaveText("Anthropic / Claude Opus 5.5 high")
10+
await expect(model.getByText("high", { exact: true })).toHaveCSS("opacity", "0.66")
911
await expect(page.locator('[data-component="composer"]')).toHaveCount(0)
12+
13+
// A status line, not a second composer card.
14+
await expect(bar).toHaveCSS("border-top-left-radius", "0px")
15+
await expect(bar).toHaveCSS("height", "32px")
16+
17+
await bar.getByRole("button", { name: /main session/i }).click()
18+
await expect(page.getByText("Opened the parent Session locally")).toBeVisible()
19+
})
20+
21+
story("a subagent with no recorded outcome starts its line at the agent", async ({ mount, page }) => {
22+
await mount("app-current-session-surface--child-session-no-outcome")
23+
24+
const bar = page.locator('[data-component="subagent-bar"]')
25+
await expect(bar.locator('[data-slot="subagent-model"]')).toBeVisible()
26+
// No empty status icon box, and no separator before the first item.
27+
await expect(bar.locator(":scope > span")).toHaveCount(0)
28+
await expect(bar).toHaveText(/^surveyor·Anthropic/)
1029
})

‎packages/app/e2e/regression/subagent-child-navigation.spec.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ test("shows the full ancestor path and navigates to an earlier subagent", async
136136
await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle])
137137
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(grandchildTitle)
138138
await expect(header.getByRole("heading", { name: greatGrandchildTitle })).toBeVisible()
139+
// Project, each ancestor and the title are all split by the same slash.
140+
const separators = header.locator('[data-slot="session-title-separator"]')
141+
await expect(separators).toHaveCount(4)
142+
await expect(separators.locator('[data-component="slash"]')).toHaveCount(4)
139143

140144
await header.getByRole("button", { name: childTitle, exact: true }).click()
141145
await expect(page).toHaveURL(sessionHref(childID))

‎packages/app/src/session/composer/subagent-bar.tsx‎

Lines changed: 63 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@ import { Icon } from "@opencode/ui/icon"
33
import { Keybind } from "@opencode/ui/keybind"
44
import { Slash } from "@opencode/ui/slash"
55
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
6-
import { createEffect, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
6+
import { createEffect, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
77
import { useLanguage } from "@/runtime/i18n/language"
88
import { formatKeybind } from "@/shell/commands/command"
99
import { formatElapsed } from "./activity-chips"
1010

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

13-
// A subagent session takes no prompts, so its composer slot shows what the run is: the composer's geometry with the
14-
// agent, model and variant where the composer's chips sit, the run's state where the placeholder sits, and the way back
15-
// where the send button sits.
13+
// A subagent session takes no prompts, so its composer slot is one quiet status line on the timeline's own surface:
14+
// the run's state and duration, the agent, model and variant, and the way back.
1615
export function SubagentBar(props: {
1716
agent?: string
1817
model?: { provider?: string; name: string; variant?: string }
@@ -24,6 +23,13 @@ export function SubagentBar(props: {
2423
const language = useLanguage()
2524
const [now, setNow] = createSignal(Date.now())
2625

26+
// Only what is known shows, with one separator between neighbours.
27+
const parts = () => [
28+
...(props.status ? (["status", "elapsed"] as const) : []),
29+
...(props.agent ? (["agent"] as const) : []),
30+
...(props.model ? (["model"] as const) : []),
31+
]
32+
2733
createEffect(() => {
2834
if (props.status !== "running") return
2935

@@ -33,24 +39,13 @@ export function SubagentBar(props: {
3339
})
3440

3541
return (
42+
// The timeline scrolls on underneath, so the line keeps its surface and fades the text passing into it.
3643
<div
3744
data-component="subagent-bar"
38-
data-dock-border-underlay="true"
39-
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"
45+
class="relative flex h-8 w-full min-w-0 items-center gap-2 bg-v2-background-bg-base ps-3 pe-1.5 text-[12px] font-[440] leading-4 tracking-[-0.04px] whitespace-nowrap tabular-nums text-v2-text-text-faint before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--v2-background-bg-base),transparent)]"
4046
>
41-
<div class="min-h-[60px] px-4 pt-4 pb-2 text-v2-text-text-faint">
42-
<Show when={props.status}>
43-
{(status) => (
44-
<>
45-
{language.t(`session.child.status.${status()}`)}
46-
{" · "}
47-
{formatElapsed((props.ended ?? now()) - props.started, language.t)}
48-
</>
49-
)}
50-
</Show>
51-
</div>
52-
<div class="flex h-11 items-center px-2">
53-
<span class="flex size-7 shrink-0 items-center justify-center text-v2-icon-icon-muted">
47+
<Show when={props.status}>
48+
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-faint">
5449
<Switch>
5550
<Match when={props.status === "running"}>
5651
<SessionProgressIndicatorV2 />
@@ -66,35 +61,56 @@ export function SubagentBar(props: {
6661
</Match>
6762
</Switch>
6863
</span>
69-
<div class="ms-1 me-3 flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap text-v2-text-text-muted">
70-
<Show when={props.agent}>
71-
{(agent) => <span class="shrink-0 px-[11px] capitalize">{agent()}</span>}
72-
</Show>
73-
<Show when={props.model}>
74-
{(model) => (
75-
<>
76-
<span data-slot="subagent-model" class="flex min-w-0 px-[11px]">
77-
<Show when={model().provider}>
78-
<span class="shrink-0 text-v2-text-text-faint">{model().provider}</span>
79-
<Slash />
80-
<span class="sr-only">{" / "}</span>
81-
</Show>
82-
<span class="truncate">{model().name}</span>
83-
</span>
84-
<Show when={model().variant !== "default" && model().variant}>
85-
<span class="shrink-0 px-[11px] capitalize">{model().variant}</span>
86-
</Show>
87-
</>
88-
)}
89-
</Show>
90-
</div>
91-
<Button type="button" variant="ghost-faint" size="small" class="gap-1.5 px-1.5" onClick={props.onBack}>
92-
{language.t("session.child.backToParent")}
93-
<span class="hidden sm:block">
94-
<Keybind keys={[formatKeybind("esc", language.t)]} variant="neutral" />
95-
</span>
96-
</Button>
64+
</Show>
65+
<div class="flex min-w-0 flex-1 items-center gap-1.5">
66+
<For each={parts()}>
67+
{(part, index) => (
68+
<>
69+
<Show when={index() > 0}>
70+
<span aria-hidden="true">·</span>
71+
</Show>
72+
<Switch>
73+
<Match when={part === "status" && props.status}>
74+
{(status) => <span class="shrink-0">{language.t(`session.child.status.${status()}`)}</span>}
75+
</Match>
76+
<Match when={part === "elapsed"}>
77+
<span class="shrink-0">{formatElapsed((props.ended ?? now()) - props.started, language.t)}</span>
78+
</Match>
79+
<Match when={part === "agent" && props.agent}>
80+
{(agent) => <span class="shrink-0 capitalize">{agent()}</span>}
81+
</Match>
82+
<Match when={part === "model" && props.model}>
83+
{(model) => (
84+
<span data-slot="subagent-model" class="flex min-w-0">
85+
<Show when={model().provider}>
86+
<span class="shrink-0">{model().provider}</span>
87+
<Slash gap={4} />
88+
<span class="sr-only">{" / "}</span>
89+
</Show>
90+
<span class="truncate">{model().name}</span>
91+
{/* Like the message metadata: the variant follows the name, dimmer, with no separator. */}
92+
<Show when={model().variant !== "default" && model().variant}>
93+
{(variant) => (
94+
<>
95+
<span class="sr-only">{" "}</span>
96+
<span class="ms-1 shrink-0 capitalize opacity-[0.66]">{variant()}</span>
97+
</>
98+
)}
99+
</Show>
100+
</span>
101+
)}
102+
</Match>
103+
</Switch>
104+
</>
105+
)}
106+
</For>
97107
</div>
108+
<Button type="button" variant="ghost-faint" size="small" class="shrink-0 gap-1.5 px-1.5" onClick={props.onBack}>
109+
{language.t("session.child.backToParent")}
110+
<span class="hidden sm:block">
111+
<Keybind keys={[formatKeybind("esc", language.t)]} variant="neutral" />
112+
</span>
113+
</Button>
98114
</div>
99115
)
100116
}
Lines changed: 8 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,22 @@
11
import { Slash } from "@opencode/ui/slash"
2-
import { Show } from "solid-js"
3-
4-
// The chevron's height and stroke, and the visible space on each side of a separator, in px.
5-
const size = 11
6-
7-
const stroke = 1
8-
9-
const gap = 10
10-
11-
// The chevron's ink fills its box, so the gap is exact; the text beside it keeps ~0.5px of side bearing.
12-
const width = size * 0.45
132

143
/**
15-
* Sits between breadcrumb segments with `gap` of visible space on both sides. `before` and `after` are the space the
16-
* neighbouring segments already leave between their ink and this element, so the margins make up the rest.
4+
* The slash between breadcrumb segments, with `gap` px of visible space on both sides. `before` and `after` are the
5+
* space the neighbouring segments already leave between their ink and this element, so the margins make up the rest.
176
*/
18-
export function SessionHeaderSeparator(props: {
19-
before?: number
20-
after?: number
21-
glyph?: "chevron" | "slash"
22-
gap?: number
23-
}) {
7+
export function SessionHeaderSeparator(props: { gap: number; before: number; after: number }) {
248
return (
259
<span
2610
data-slot="session-title-separator"
27-
data-glyph={props.glyph ?? "chevron"}
28-
class="session-header-separator text-v2-text-text-faint"
11+
class="session-header-separator"
2912
style={{
30-
"--separator-gap": `${props.gap ?? gap}px`,
31-
"--separator-before": props.before === undefined ? undefined : `${props.before}px`,
32-
"--separator-after": props.after === undefined ? undefined : `${props.after}px`,
13+
"--separator-gap": `${props.gap}px`,
14+
"--separator-before": `${props.before}px`,
15+
"--separator-after": `${props.after}px`,
3316
}}
3417
aria-hidden="true"
3518
>
36-
<Show when={props.glyph !== "slash"} fallback={<Slash gap={0} />}>
37-
<svg
38-
class="block fill-none stroke-current"
39-
width={width}
40-
height={size}
41-
viewBox={`0 0 ${width} ${size}`}
42-
stroke-width={stroke}
43-
stroke-linecap="round"
44-
stroke-linejoin="round"
45-
>
46-
<path
47-
d={`M${stroke / 2} ${stroke / 2}L${width - stroke / 2} ${size / 2}L${stroke / 2} ${size - stroke / 2}`}
48-
/>
49-
</svg>
50-
</Show>
19+
<Slash gap={0} />
5120
</span>
5221
)
5322
}

‎packages/app/src/session/session-identity-header.css‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,9 @@
9393
flex-shrink: 0;
9494
align-items: center;
9595
/* The neighbouring segments' own padding counts toward the gap; 0.5px is the text's side bearing. */
96-
margin-inline: calc(var(--separator-gap) - var(--separator-before, 6px) - 0.5px)
97-
calc(var(--separator-gap) - var(--separator-after, 6px) - 0.5px);
98-
}
99-
100-
/* The header's segments are 13px text, and the slash takes their size. */
101-
.session-header-separator[data-glyph="slash"] {
96+
margin-inline: calc(var(--separator-gap) - var(--separator-before) - 0.5px)
97+
calc(var(--separator-gap) - var(--separator-after) - 0.5px);
98+
/* The header's segments are 13px text, and the slash takes their size. */
10299
font-size: 13px;
103100
line-height: var(--line-height-compact);
104101
}
105-
106-
.session-header-separator[data-glyph="chevron"]:dir(rtl) > svg {
107-
transform: scaleX(-1);
108-
}

‎packages/app/src/session/session-identity-header.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export function SessionLocation(props: {
232232
<Show when={!props.compact && worktree()}>
233233
{(name) => (
234234
<>
235-
<SessionHeaderSeparator glyph="slash" gap={0} before={0} after={0} />
235+
<SessionHeaderSeparator gap={0} before={0} after={0} />
236236
<Icon
237237
name="outline-worktree"
238238
class="session-location-worktree-icon shrink-0 text-v2-icon-icon-muted"
@@ -378,7 +378,7 @@ export function SessionLocation(props: {
378378
</Menu.Portal>
379379
</Menu>
380380
<Show when={props.trailing}>
381-
<SessionHeaderSeparator glyph="slash" gap={8} before={6} after={4} />
381+
<SessionHeaderSeparator gap={8} before={6} after={4} />
382382
</Show>
383383
</>
384384
)
@@ -445,14 +445,14 @@ export function SessionAncestorTrail(props: {
445445
<bdi dir="auto">{ancestor.title}</bdi>
446446
</button>
447447
<Show when={index() < ancestors().length - 1}>
448-
<SessionHeaderSeparator before={0} after={0} />
448+
<SessionHeaderSeparator gap={8} before={0} after={0} />
449449
</Show>
450450
</>
451451
)}
452452
</For>
453453
</div>
454454
<Show when={props.trailing}>
455-
<SessionHeaderSeparator before={0} />
455+
<SessionHeaderSeparator gap={8} before={0} after={4} />
456456
</Show>
457457
</div>
458458
)

‎packages/app/src/session/session-screen.stories.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,21 @@ export const ChildSession = {
282282
),
283283
}
284284

285+
export const ChildSessionNoOutcome = {
286+
render: () => (
287+
<SessionPreview
288+
title="Inspect a child Session with no recorded outcome"
289+
description={description}
290+
document={subagentDocument}
291+
child={{
292+
agent: "surveyor",
293+
model: { provider: "Anthropic", name: "Claude Opus 5.5", variant: "high" },
294+
started: 0,
295+
}}
296+
/>
297+
),
298+
}
299+
285300
export const ChildSessionDone = {
286301
render: () => (
287302
<SessionPreview

0 commit comments

Comments
 (0)