Skip to content
Merged
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
26 changes: 26 additions & 0 deletions packages/app/component-tests/activity-chips.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,29 @@ story("shows how long each background item has been running, ticking while open"
await expect(elapsed).toHaveText(["13s", "1m 06s", "1h 02m", "5m 42s"])
expect(await page.locator('[data-slot="session-activity-panel"]').evaluate((node) => node.clientWidth)).toBe(width)
})

story("opens a background shell's output in the menu and returns focus on the way out", async ({ mount, page }) => {
await mount("session-activity-chips--subagents-and-shells")

const chip = page.locator('[data-slot="session-activity-chip"]')
const panel = page.locator('[data-slot="session-activity-panel"]')
await chip.click()
await panel.getByRole("menuitem", { name: /bun test --watch/ }).click()

// The story's shells print `$ <id>` and `Ready.`.
await expect(panel.locator('[data-slot="session-activity-shell-output"]')).toHaveText(/\$ shl_test\s+Ready\./)
await expect(panel.locator('[data-slot="session-activity-back"]')).toBeFocused()

await page.keyboard.press("Enter")
await expect(panel.getByRole("menuitem", { name: /bun test --watch/ })).toBeFocused()

await panel.getByRole("menuitem", { name: /bun test --watch/ }).click()
await expect(panel.locator('[data-slot="session-activity-back"]')).toBeFocused()
await page.keyboard.press("Escape")
await expect(panel).toBeHidden()
await expect(chip).toBeFocused()

// Closing forgets the shell, so the menu opens on the list again.
await chip.click()
await expect(panel).toHaveAttribute("data-view", "list")
})
86 changes: 29 additions & 57 deletions packages/app/e2e/regression/timeline-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1352,14 +1352,11 @@ test.describe("background shortcut", () => {
/^Explore\s*Background task\d+h \d{2}m$/,
/^sleep 120\d+h \d{2}m$/,
])
// A shell opens its output in the menu rather than revealing its call in the timeline.
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
await expect(list).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shellCall = page.locator('[data-timeline-part-id="call_shell_backgrounded"]')
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(shellCall).toBeInViewport()

await activity.click()
await expect(list).toHaveAttribute("data-view", "shell")
await expect(used).toHaveAttribute("aria-expanded", "false")
await list.getByRole("menuitem", { name: "Back to background work", exact: true }).click()

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

await timeline.transport.send({
id: "evt_far_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_far",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/far.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})

const group = page
.locator('[data-component="collapsed-tool-group"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')

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

const aligned = () =>
expect
.poll(async () => {
const [header, shell] = await Promise.all([group.boundingBox(), shellTrigger.boundingBox()])
const [header, row] = await Promise.all([group.boundingBox(), call.boundingBox()])

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

await expect(
fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group,
).toBeInViewport()
await expect(shellTrigger).toHaveCount(0)
await page.getByRole("button", { name: "1 shell", exact: true }).click()
await expect(call).toHaveCount(0)
await page.getByRole("button", { name: "1 subagent", exact: true }).click()
await page
.getByRole("menu", { name: "1 shell", exact: true })
.getByRole("menuitem", { name: /sleep 120/ })
.getByRole("menu", { name: "1 subagent", exact: true })
.getByRole("menuitem", { name: /Map the far code/ })
.click()

await expect(group).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toBeInViewport()
await expect(call).toBeInViewport()

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

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

return {
Expand Down Expand Up @@ -2245,11 +2215,13 @@ test("keeps the native selection height after quoting wrapped and formatted text
const height = await source.locator("p").evaluate((element) => parseFloat(getComputedStyle(element).lineHeight))
await expect
.poll(() =>
source.locator('[data-component="timeline-quote-background"] > span').evaluateAll(
(lines, height) =>
lines.length > 0 && lines.every((line) => Math.abs(line.getBoundingClientRect().height - height) < 0.1),
height,
),
source
.locator('[data-component="timeline-quote-background"] > span')
.evaluateAll(
(lines, height) =>
lines.length > 0 && lines.every((line) => Math.abs(line.getBoundingClientRect().height - height) < 0.1),
height,
),
)
.toBe(true)
})
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,14 @@ export const dict = {
"session.activity.subagents.other": "{{count}} subagents",
"session.activity.shells.one": "{{count}} shell",
"session.activity.shells.other": "{{count}} shells",
"session.activity.shell.back": "Back to background work",
"session.activity.shell.stop": "Stop",
"session.activity.shell.stopping": "Stopping…",
"session.activity.shell.exited": "Exited",
"session.activity.shell.exitCode": "Exited with code {{code}}",
"session.activity.shell.timeout": "Timed out",
"session.activity.shell.killed": "Stopped",
"session.activity.shell.empty": "No output yet",
"session.activity.summary": "{{subagents}} · {{shells}}",
"session.activity.section.subagents": "Subagents",
"session.activity.section.shells": "Shells",
Expand Down
105 changes: 105 additions & 0 deletions packages/app/src/session/composer/activity-chips.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,111 @@
}
}

/* One shell: a wider panel with its header row over the tail of its output. Switching views changes size at once. */
&[data-view="shell"] {
display: flex;
flex-direction: column;
width: min(560px, calc(100vw - 56px));
max-width: none;
max-height: min(320px, var(--kb-popper-content-available-height, 320px));
overflow: hidden;
}

[data-slot="session-activity-shell-header"] {
display: flex;
flex: none;
align-items: center;
gap: 8px;
min-height: 28px;
padding-inline-end: 2px;

[data-component="menu-v2-item"],
[data-slot="menu-v2-item-content"] {
display: flex;
flex: none;
}

[data-component="menu-v2-item"] {
padding-inline: 6px;
}

/* Matches the list's glyph column, so the arrow sits where the row icons were. */
[data-slot="session-activity-back"] {
width: 28px;
justify-content: center;
--menu-v2-fg: var(--v2-icon-icon-muted);

[data-slot="menu-v2-item-content"] {
flex: none;
}

svg:dir(rtl) {
transform: scaleX(-1);
}
}

[data-slot="session-activity-shell-stop"][aria-disabled="true"] {
--menu-v2-fg: var(--v2-text-text-muted);
}
}

[data-slot="session-activity-shell-command"] {
font-family: var(--v2-font-family-code);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
text-align: left;
}

[data-slot="session-activity-shell-status"] {
flex: none;
padding-inline: 6px;
font-size: 13px;
line-height: var(--line-height-compact);
white-space: nowrap;
color: var(--v2-text-text-muted);

&[data-failed] {
color: var(--v2-state-fg-danger);
}
}

[data-slot="session-activity-shell-output"] {
min-height: 0;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
margin-top: 4px;
padding: 8px 10px;
border-radius: 6px;
background: var(--v2-background-bg-base);

pre {
margin: 0;
text-align: left;
}

code {
display: block;
font-family: var(--v2-font-family-code);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}

[data-slot="session-activity-shell-empty"] {
font-size: 13px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}

:root[data-motion="reduce"] & {
animation: none;
}
Expand Down
11 changes: 7 additions & 4 deletions packages/app/src/session/composer/activity-chips.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ type PreviewArgs = { stage: number; subagents: number; shells: number }
// Driven by args, so moving between these stories updates the same capsule and its transitions play.
function Preview(props: PreviewArgs) {
const value = createMemo(() => liveDocument(stages[props.stage] ?? stages[0]))
// Built once per count, so each item keeps the start time it had when it appeared.

const activity = createMemo(() => ({
subagents: Array.from({ length: props.subagents }, (_, index) => subagentItem(index)),
shells: Array.from({ length: props.shells }, (_, index) => shellItem(index)),
}))

onMount(() => pin("auto"))
createEffect(on(value, () => pin("smooth"), { defer: true }))
Expand All @@ -74,10 +80,7 @@ function Preview(props: PreviewArgs) {
description="opencode · activity chips"
document={value()}
timelineDetail={timelineDetail}
activity={{
subagents: Array.from({ length: props.subagents }, (_, index) => subagentItem(index)),
shells: Array.from({ length: props.shells }, (_, index) => shellItem(index)),
}}
activity={activity()}
/>
)
}
Expand Down
Loading
Loading