Skip to content

Commit 97fd7ac

Browse files
authored
feat(app): show a background shell's output from the pill (#59496)
1 parent a97dc23 commit 97fd7ac

12 files changed

Lines changed: 844 additions & 124 deletions

File tree

‎packages/app/component-tests/activity-chips.spec.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,29 @@ story("shows how long each background item has been running, ticking while open"
1919
await expect(elapsed).toHaveText(["13s", "1m 06s", "1h 02m", "5m 42s"])
2020
expect(await page.locator('[data-slot="session-activity-panel"]').evaluate((node) => node.clientWidth)).toBe(width)
2121
})
22+
23+
story("opens a background shell's output in the menu and returns focus on the way out", async ({ mount, page }) => {
24+
await mount("session-activity-chips--subagents-and-shells")
25+
26+
const chip = page.locator('[data-slot="session-activity-chip"]')
27+
const panel = page.locator('[data-slot="session-activity-panel"]')
28+
await chip.click()
29+
await panel.getByRole("menuitem", { name: /bun test --watch/ }).click()
30+
31+
// The story's shells print `$ <id>` and `Ready.`.
32+
await expect(panel.locator('[data-slot="session-activity-shell-output"]')).toHaveText(/\$ shl_test\s+Ready\./)
33+
await expect(panel.locator('[data-slot="session-activity-back"]')).toBeFocused()
34+
35+
await page.keyboard.press("Enter")
36+
await expect(panel.getByRole("menuitem", { name: /bun test --watch/ })).toBeFocused()
37+
38+
await panel.getByRole("menuitem", { name: /bun test --watch/ }).click()
39+
await expect(panel.locator('[data-slot="session-activity-back"]')).toBeFocused()
40+
await page.keyboard.press("Escape")
41+
await expect(panel).toBeHidden()
42+
await expect(chip).toBeFocused()
43+
44+
// Closing forgets the shell, so the menu opens on the list again.
45+
await chip.click()
46+
await expect(panel).toHaveAttribute("data-view", "list")
47+
})

‎packages/app/e2e/regression/timeline-stream.spec.ts‎

Lines changed: 29 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,14 +1352,11 @@ test.describe("background shortcut", () => {
13521352
/^Explore\s*Background task\d+h \d{2}m$/,
13531353
/^sleep 120\d+h \d{2}m$/,
13541354
])
1355+
// A shell opens its output in the menu rather than revealing its call in the timeline.
13551356
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
1356-
await expect(list).toHaveCount(0)
1357-
await expect(used).toHaveAttribute("aria-expanded", "true")
1358-
const shellCall = page.locator('[data-timeline-part-id="call_shell_backgrounded"]')
1359-
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
1360-
await expect(shellCall).toBeInViewport()
1361-
1362-
await activity.click()
1357+
await expect(list).toHaveAttribute("data-view", "shell")
1358+
await expect(used).toHaveAttribute("aria-expanded", "false")
1359+
await list.getByRole("menuitem", { name: "Back to background work", exact: true }).click()
13631360

13641361
const interrupt = page.waitForRequest(
13651362
(request) =>
@@ -1384,6 +1381,7 @@ test.describe("background shortcut", () => {
13841381
await expect(list).toHaveCount(0)
13851382
await expect(backgroundCard).toContainText("Background task (background)")
13861383
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
1384+
await used.click()
13871385
await expect(
13881386
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
13891387
).toHaveAttribute("data-active", "true")
@@ -1407,24 +1405,19 @@ test.describe("background shortcut", () => {
14071405
// Reaching the shell lands on the end of the timeline, which would otherwise follow new content again.
14081406
{ name: "at the end of the timeline", before: 40, searches: 0, after: 0 },
14091407
]) {
1410-
test(`holds a revealed shell ${fixture.name} just under its stuck group header`, async ({ page }) => {
1408+
// A starting subagent call is the pill's row that reveals its call; grouping subagents puts it inside the group.
1409+
test(`holds a revealed call ${fixture.name} just under its stuck group header`, async ({ page }) => {
14111410
const timeline = await setupTimeline(page, {
1412-
settings: { timelineDetail: detailed },
1411+
settings: { timelineDetail: { ...detailed, subagents: { placement: "grouped" } } },
14131412
sessionStatus: { [sessionID]: { type: "busy" } },
14141413
messages: [
14151414
userMessage(),
14161415
assistantMessage(
14171416
[
14181417
...(fixture.before ? [textPart("prt_earlier", notes("Earlier", fixture.before))] : []),
14191418
...searches(0, 42 - fixture.searches),
1420-
toolPart(
1421-
"prt_far_shell",
1422-
"shell",
1423-
"completed",
1424-
{ command: "sleep 120" },
1425-
{ output: "working", metadata: { shellID: "shell_far", status: "running" } },
1426-
),
1427-
// The open group outgrows the viewport, so aligning the group instead of the shell misses it.
1419+
toolPart("prt_far_call", "subagent", "running", { description: "Map the far code", agent: "explore" }),
1420+
// The open group outgrows the viewport, so aligning the group instead of the call misses it.
14281421
...searches(42 - fixture.searches, fixture.searches),
14291422
...(fixture.after ? [textPart("prt_follow_up", notes("Follow-up", fixture.after))] : []),
14301423
],
@@ -1433,60 +1426,40 @@ test.describe("background shortcut", () => {
14331426
],
14341427
})
14351428

1436-
await timeline.transport.send({
1437-
id: "evt_far_shell_created",
1438-
created: 3,
1439-
type: "shell.created",
1440-
location: { directory },
1441-
data: {
1442-
info: {
1443-
id: "shell_far",
1444-
status: "running",
1445-
command: "sleep 120",
1446-
cwd: directory,
1447-
shell: "bash",
1448-
file: "/tmp/far.out",
1449-
metadata: { sessionID },
1450-
time: { started: 2 },
1451-
},
1452-
},
1453-
})
1454-
14551429
const group = page
14561430
.locator('[data-component="collapsed-tool-group"]')
14571431
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
14581432

1459-
const shellTrigger = page.locator('[data-timeline-part-id="prt_far_shell"] [data-slot="collapsible-trigger"]')
1433+
const call = page.locator('[data-timeline-part-id="prt_far_call"]')
14601434

14611435
const aligned = () =>
14621436
expect
14631437
.poll(async () => {
1464-
const [header, shell] = await Promise.all([group.boundingBox(), shellTrigger.boundingBox()])
1438+
const [header, row] = await Promise.all([group.boundingBox(), call.boundingBox()])
14651439

1466-
return header && shell ? Math.round(shell.y - (header.y + header.height)) : undefined
1440+
return header && row ? Math.round(row.y - (header.y + header.height)) : undefined
14671441
})
14681442
.toBe(0)
14691443

14701444
await expect(
14711445
fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group,
14721446
).toBeInViewport()
1473-
await expect(shellTrigger).toHaveCount(0)
1474-
await page.getByRole("button", { name: "1 shell", exact: true }).click()
1447+
await expect(call).toHaveCount(0)
1448+
await page.getByRole("button", { name: "1 subagent", exact: true }).click()
14751449
await page
1476-
.getByRole("menu", { name: "1 shell", exact: true })
1477-
.getByRole("menuitem", { name: /sleep 120/ })
1450+
.getByRole("menu", { name: "1 subagent", exact: true })
1451+
.getByRole("menuitem", { name: /Map the far code/ })
14781452
.click()
14791453

14801454
await expect(group).toHaveAttribute("aria-expanded", "true")
1481-
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
1482-
await expect(shellTrigger).toBeInViewport()
1455+
await expect(call).toBeInViewport()
14831456

1484-
// A shell at the very end cannot rise under the header until more content arrives below it.
1457+
// A call at the very end cannot rise under the header until more content arrives below it.
14851458
if (fixture.searches) await aligned()
14861459
// Nothing covers the row, so it takes the next click.
1487-
await shellTrigger.click({ trial: true })
1460+
await call.click({ trial: true })
14881461

1489-
// The agent keeps writing below the shell; the shell stays where it was revealed.
1462+
// The agent keeps writing below the call; the call stays where it was revealed.
14901463
await timeline.send(partUpdated(textPart("prt_late", notes("Late", 30))))
14911464
await expect(page.getByText("Late note 30.", { exact: true })).toBeAttached()
14921465
await aligned()
@@ -2142,10 +2115,7 @@ test("highlights a quote's source only while its first comment is written", asyn
21422115
await expect
21432116
.poll(() =>
21442117
first.evaluate((element) =>
2145-
[
2146-
"::selection",
2147-
"::highlight(timeline-quote-source)",
2148-
].map((pseudo) => {
2118+
["::selection", "::highlight(timeline-quote-source)"].map((pseudo) => {
21492119
const style = getComputedStyle(element, pseudo)
21502120

21512121
return {
@@ -2245,11 +2215,13 @@ test("keeps the native selection height after quoting wrapped and formatted text
22452215
const height = await source.locator("p").evaluate((element) => parseFloat(getComputedStyle(element).lineHeight))
22462216
await expect
22472217
.poll(() =>
2248-
source.locator('[data-component="timeline-quote-background"] > span').evaluateAll(
2249-
(lines, height) =>
2250-
lines.length > 0 && lines.every((line) => Math.abs(line.getBoundingClientRect().height - height) < 0.1),
2251-
height,
2252-
),
2218+
source
2219+
.locator('[data-component="timeline-quote-background"] > span')
2220+
.evaluateAll(
2221+
(lines, height) =>
2222+
lines.length > 0 && lines.every((line) => Math.abs(line.getBoundingClientRect().height - height) < 0.1),
2223+
height,
2224+
),
22532225
)
22542226
.toBe(true)
22552227
})

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,14 @@ export const dict = {
628628
"session.activity.subagents.other": "{{count}} subagents",
629629
"session.activity.shells.one": "{{count}} shell",
630630
"session.activity.shells.other": "{{count}} shells",
631+
"session.activity.shell.back": "Back to background work",
632+
"session.activity.shell.stop": "Stop",
633+
"session.activity.shell.stopping": "Stopping…",
634+
"session.activity.shell.exited": "Exited",
635+
"session.activity.shell.exitCode": "Exited with code {{code}}",
636+
"session.activity.shell.timeout": "Timed out",
637+
"session.activity.shell.killed": "Stopped",
638+
"session.activity.shell.empty": "No output yet",
631639
"session.activity.summary": "{{subagents}} · {{shells}}",
632640
"session.activity.section.subagents": "Subagents",
633641
"session.activity.section.shells": "Shells",

‎packages/app/src/session/composer/activity-chips.css‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,111 @@
142142
}
143143
}
144144

145+
/* One shell: a wider panel with its header row over the tail of its output. Switching views changes size at once. */
146+
&[data-view="shell"] {
147+
display: flex;
148+
flex-direction: column;
149+
width: min(560px, calc(100vw - 56px));
150+
max-width: none;
151+
max-height: min(320px, var(--kb-popper-content-available-height, 320px));
152+
overflow: hidden;
153+
}
154+
155+
[data-slot="session-activity-shell-header"] {
156+
display: flex;
157+
flex: none;
158+
align-items: center;
159+
gap: 8px;
160+
min-height: 28px;
161+
padding-inline-end: 2px;
162+
163+
[data-component="menu-v2-item"],
164+
[data-slot="menu-v2-item-content"] {
165+
display: flex;
166+
flex: none;
167+
}
168+
169+
[data-component="menu-v2-item"] {
170+
padding-inline: 6px;
171+
}
172+
173+
/* Matches the list's glyph column, so the arrow sits where the row icons were. */
174+
[data-slot="session-activity-back"] {
175+
width: 28px;
176+
justify-content: center;
177+
--menu-v2-fg: var(--v2-icon-icon-muted);
178+
179+
[data-slot="menu-v2-item-content"] {
180+
flex: none;
181+
}
182+
183+
svg:dir(rtl) {
184+
transform: scaleX(-1);
185+
}
186+
}
187+
188+
[data-slot="session-activity-shell-stop"][aria-disabled="true"] {
189+
--menu-v2-fg: var(--v2-text-text-muted);
190+
}
191+
}
192+
193+
[data-slot="session-activity-shell-command"] {
194+
font-family: var(--v2-font-family-code);
195+
font-size: 13px;
196+
font-weight: 440;
197+
line-height: var(--line-height-compact);
198+
letter-spacing: -0.04px;
199+
color: var(--v2-text-text-base);
200+
text-align: left;
201+
}
202+
203+
[data-slot="session-activity-shell-status"] {
204+
flex: none;
205+
padding-inline: 6px;
206+
font-size: 13px;
207+
line-height: var(--line-height-compact);
208+
white-space: nowrap;
209+
color: var(--v2-text-text-muted);
210+
211+
&[data-failed] {
212+
color: var(--v2-state-fg-danger);
213+
}
214+
}
215+
216+
[data-slot="session-activity-shell-output"] {
217+
min-height: 0;
218+
flex: 1 1 auto;
219+
overflow-x: hidden;
220+
overflow-y: auto;
221+
margin-top: 4px;
222+
padding: 8px 10px;
223+
border-radius: 6px;
224+
background: var(--v2-background-bg-base);
225+
226+
pre {
227+
margin: 0;
228+
text-align: left;
229+
}
230+
231+
code {
232+
display: block;
233+
font-family: var(--v2-font-family-code);
234+
font-size: 13px;
235+
font-weight: 440;
236+
line-height: var(--line-height-compact);
237+
letter-spacing: -0.04px;
238+
color: var(--v2-text-text-base);
239+
white-space: pre-wrap;
240+
overflow-wrap: anywhere;
241+
}
242+
}
243+
244+
[data-slot="session-activity-shell-empty"] {
245+
font-size: 13px;
246+
line-height: var(--line-height-compact);
247+
color: var(--v2-text-text-muted);
248+
}
249+
145250
:root[data-motion="reduce"] & {
146251
animation: none;
147252
}

‎packages/app/src/session/composer/activity-chips.stories.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ type PreviewArgs = { stage: number; subagents: number; shells: number }
6464
// Driven by args, so moving between these stories updates the same capsule and its transitions play.
6565
function Preview(props: PreviewArgs) {
6666
const value = createMemo(() => liveDocument(stages[props.stage] ?? stages[0]))
67+
// Built once per count, so each item keeps the start time it had when it appeared.
68+
69+
const activity = createMemo(() => ({
70+
subagents: Array.from({ length: props.subagents }, (_, index) => subagentItem(index)),
71+
shells: Array.from({ length: props.shells }, (_, index) => shellItem(index)),
72+
}))
6773

6874
onMount(() => pin("auto"))
6975
createEffect(on(value, () => pin("smooth"), { defer: true }))
@@ -74,10 +80,7 @@ function Preview(props: PreviewArgs) {
7480
description="opencode · activity chips"
7581
document={value()}
7682
timelineDetail={timelineDetail}
77-
activity={{
78-
subagents: Array.from({ length: props.subagents }, (_, index) => subagentItem(index)),
79-
shells: Array.from({ length: props.shells }, (_, index) => shellItem(index)),
80-
}}
83+
activity={activity()}
8184
/>
8285
)
8386
}

0 commit comments

Comments
 (0)