feat(app): show background activity capsule above composer

This commit is contained in:
Dax Raad
2026-10-18 16:39:02 -04:00
Unverified
parent cf6b361268
commit 838b8f10a0
7 changed files with 522 additions and 4 deletions
+7
View File
@@ -545,6 +545,13 @@ export const dict = {
"session.messages.loading": "Loading messages…",
"session.messages.jumpToLatest": "Jump to latest",
"session.activity.subagents.one": "{{count}} subagent",
"session.activity.subagents.other": "{{count}} subagents",
"session.activity.shells.one": "{{count}} shell",
"session.activity.shells.other": "{{count}} shells",
"session.activity.summary": "{{subagents}} · {{shells}}",
"session.activity.section.subagents": "Subagents",
"session.activity.section.shells": "Shells",
"session.question.progress": "{{current}} of {{total}} questions",
"session.question.title": "Question",
@@ -277,6 +277,16 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
// "2 subagents · 1 shell", or one group alone, as the iOS app's background-work capsule reads.
const activitySummary = (counts: { subagents: number; shells: number }) => {
const subagents = counts.subagents ? plural("session.activity.subagents", counts.subagents) : undefined
const shells = counts.shells ? plural("session.activity.shells", counts.shells) : undefined
if (subagents && shells) return t("session.activity.summary", { subagents, shells })
return subagents ?? shells ?? ""
}
const tDynamic = <Key extends Extract<keyof Dictionary, string>>(
key: TranslationKey<Key>,
source: string,
@@ -326,6 +336,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
tDynamic,
plural,
pluralForm,
activitySummary,
rich,
list,
setLocale(next: Locale) {
@@ -0,0 +1,65 @@
[data-component="session-activity-glyph"] {
display: block;
flex-shrink: 0;
fill: currentColor;
}
/* One wave runs diagonally across the 5×5 grid, as in the iOS app's agent activity glyph. */
[data-component="session-activity-glyph"] [data-dot] {
animation: session-activity-glyph 1.1s linear infinite;
animation-delay: calc(var(--session-activity-glyph-phase, 0ms) + (var(--diagonal) / 8 - 1) * 1.1s);
}
[data-component="session-activity-glyph"] [data-reduced-motion] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-activity-glyph"] [data-dot] {
display: none;
}
[data-component="session-activity-glyph"] [data-reduced-motion] {
display: block;
}
}
/* opacity = 0.16 + 0.84 * wave³, wave = (1 + cos 2πt) / 2 */
@keyframes session-activity-glyph {
0%,
100% {
opacity: 1;
}
5%,
95% {
opacity: 0.94;
}
10%,
90% {
opacity: 0.782;
}
15%,
85% {
opacity: 0.58;
}
20%,
80% {
opacity: 0.396;
}
25%,
75% {
opacity: 0.265;
}
30%,
70% {
opacity: 0.195;
}
35%,
65% {
opacity: 0.167;
}
40%,
60% {
opacity: 0.16;
}
}
@@ -0,0 +1,240 @@
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client/promise"
import type { SessionDocument } from "@opencode/session-ui/document"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
editThenTestDocument,
largeCompletedDocument,
} from "@opencode/session-ui/storybook"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionPreview } from "@/session/story-model"
import type { SessionActivityItem } from "./activity-chips"
// The app's default timeline preset.
const timelineDetail = timelinePresets[2].value
const subagents = [
{ id: "ses_child_audit", agent: "explore", description: "Audit the timeline projection" },
{ id: "ses_child_review", agent: "general", description: "Review the composer dock" },
{ id: "ses_child_search", agent: "explore", description: "Search for shell metadata" },
]
const shells = [
{ id: "shl_dev", command: "bun run dev -- --port 4444" },
{ id: "shl_test", command: "bun test --watch src/session" },
]
const subagentItem = (index: number): SessionActivityItem => {
const value = subagents[index % subagents.length]
return {
key: `${value.id}_${index}`,
type: "subagent",
label: value.description,
agent: value.agent,
sessionID: value.id,
target: value.id,
}
}
const shellItem = (index: number): SessionActivityItem => ({
key: `${shells[index % shells.length].id}_${index}`,
type: "shell",
label: shells[index % shells.length].command,
target: shells[index % shells.length].id,
})
// The timeline follows new work at the bottom, like a pinned session.
// Rows measure after the first frame, so wait past it.
const pin = (behavior: ScrollBehavior) =>
setTimeout(() => {
const timeline = document.querySelector('[data-slot="story-timeline"]')
timeline?.scrollTo({ top: timeline.scrollHeight, behavior })
}, 150)
const preview = (stage: number, subagentCount: number, shellCount: number) => () => {
const value = liveDocument(stages[stage])
onMount(() => pin("auto"))
return (
<SessionPreview
title="Run background work"
description="opencode · activity chips"
document={value}
timelineDetail={timelineDetail}
activity={{
subagents: Array.from({ length: subagentCount }, (_, index) => subagentItem(index)),
shells: Array.from({ length: shellCount }, (_, index) => shellItem(index)),
}}
/>
)
}
// Each stage lists the work still running; finished work stays in the timeline as completed calls.
const stages = [
{ started: 0, finished: 0, shells: 0, finishedShells: 0 },
{ started: 1, finished: 0, shells: 0, finishedShells: 0 },
{ started: 2, finished: 0, shells: 0, finishedShells: 0 },
{ started: 2, finished: 0, shells: 1, finishedShells: 0 },
{ started: 2, finished: 0, shells: 2, finishedShells: 0 },
{ started: 2, finished: 1, shells: 2, finishedShells: 0 },
{ started: 2, finished: 2, shells: 2, finishedShells: 0 },
{ started: 2, finished: 2, shells: 2, finishedShells: 1 },
{ started: 2, finished: 2, shells: 2, finishedShells: 2 },
]
type Stage = (typeof stages)[number]
function liveDocument(stage: Stage): SessionDocument {
const done = stage.finished === stage.started && stage.finishedShells === stage.shells && stage.started > 0
const tools = [
...subagents.slice(0, stage.started).map(
(value, index): SessionMessageAssistantTool => ({
type: "tool",
id: `tool_${value.id}`,
name: "subagent",
state:
index < stage.finished
? {
status: "completed",
input: { agent: value.agent, description: value.description, prompt: value.description },
content: [{ type: "text", text: "No issues found." }],
metadata: { sessionID: value.id, status: "completed" },
}
: {
status: "running",
input: { agent: value.agent, description: value.description, prompt: value.description },
metadata: { sessionID: value.id },
},
time: { created: STORY_TIME + 301_000 + index * 1_000, ran: STORY_TIME + 301_100 + index * 1_000 },
}),
),
...shells.slice(0, stage.shells).map(
(value, index): SessionMessageAssistantTool => ({
type: "tool",
id: `tool_${value.id}`,
name: "shell",
state: {
status: "completed",
input: { command: value.command, description: value.command },
content: [{ type: "text", text: index === 0 ? "Local: http://localhost:4444/" : "Watching src/session" }],
metadata: { shellID: value.id, status: index < stage.finishedShells ? "exited" : "running" },
},
time: { created: STORY_TIME + 304_000 + index * 1_000, ran: STORY_TIME + 304_100 + index * 1_000 },
}),
),
]
const messages: SessionMessageInfo[] = [
...editThenTestDocument.messages,
...largeCompletedDocument.messages.slice(-8),
{
id: "msg_user_activity",
type: "user",
text: "Audit the activity chips and keep the dev server and tests running while you work.",
time: { created: STORY_TIME + 300_000 },
metadata: { agent: "build", model: STORY_MODEL },
},
{
id: "msg_assistant_activity",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [
{
type: "text",
text: "I'll delegate the audit to subagents and keep the dev server and tests running in the background.",
},
...tools,
...(done
? [{ type: "text" as const, text: "Both audits passed and the background commands have exited." }]
: []),
],
time: { created: STORY_TIME + 300_500, completed: done ? STORY_TIME + 310_000 : undefined },
},
]
return { sessionID: CURRENT_SESSION_ID, messages, status: done ? { type: "idle" } : { type: "busy" }, diffs: [] }
}
// Scripted with `activity-story-stage` events on `document` so a recording can step through the work.
function LiveActivity() {
const [story, setStory] = createStore({ stage: 0 })
const stage = createMemo(() => stages[story.stage])
const value = createMemo(() => liveDocument(stage()))
onMount(() => {
const step = (event: Event) => {
if (!(event instanceof CustomEvent)) return
setStory("stage", Number(event.detail))
pin("smooth")
}
document.addEventListener("activity-story-stage", step)
onCleanup(() => document.removeEventListener("activity-story-stage", step))
pin("auto")
})
return (
<SessionPreview
title="Audit the activity chips"
description="opencode · activity chips"
document={value()}
timelineDetail={timelineDetail}
activity={{
subagents: subagents
.slice(stage().finished, stage().started)
.map((_, index) => subagentItem(index + stage().finished)),
shells: shells
.slice(stage().finishedShells, stage().shells)
.map((_, index) => shellItem(index + stage().finishedShells)),
onOpen: (item) => {
// The app opens the shell's tool call, or the group holding it, and centers it in the timeline.
const part = document.querySelector(
`[data-slot="story-timeline"] [data-timeline-part-ids*="tool_${item.target}"]`,
)
const trigger = part?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"][aria-expanded="false"]')
trigger?.click()
requestAnimationFrame(() => part?.scrollIntoView({ block: "center", behavior: "smooth" }))
},
}}
/>
)
}
export default {
title: "OpenCode/Session/Activity chips",
id: "session-activity-chips",
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Running subagents and background shells in one capsule above the production Composer. It opens a menu of the work.",
},
},
},
}
export const NoActivity = { render: preview(0, 0, 0) }
export const OneSubagent = { render: preview(1, 1, 0) }
export const TwoSubagents = { render: preview(2, 2, 0) }
export const OneShell = { render: preview(7, 0, 1) }
export const SubagentsAndShells = { render: preview(3, 2, 1) }
export const ManySubagents = { render: preview(4, 12, 2) }
export const Live = { render: () => <LiveActivity /> }
@@ -0,0 +1,116 @@
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { For, Index, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./activity-chips.css"
export type SessionActivityItem = {
key: string
type: "subagent" | "shell"
label?: string
agent?: string
sessionID?: string
target: string
}
// Live work above the composer, one capsule like the iOS app's background-work summary.
export function SessionActivityChips(props: {
subagents: readonly SessionActivityItem[]
shells: readonly SessionActivityItem[]
onOpen: (item: SessionActivityItem) => void
}) {
const language = useLanguage()
const label = () => language.activitySummary({ subagents: props.subagents.length, shells: props.shells.length })
return (
<Show when={props.subagents.length > 0 || props.shells.length > 0}>
<div data-component="session-activity-chips" class="flex items-center pb-2">
<Menu gutter={6} placement="top-start">
<Menu.Trigger
as="button"
type="button"
data-slot="session-activity-chip"
class="flex h-7 min-w-0 max-w-full items-center gap-1.5 rounded-full border border-v2-border-border-base bg-v2-background-bg-base ps-2 pe-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap tabular-nums text-v2-text-text-base shadow-[var(--v2-elevation-raised)] outline-none duration-150 ease-out animate-in fade-in motion-reduce:animate-none hover:bg-v2-background-bg-layer-01 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-background-bg-layer-01"
>
<SessionActivityGlyph />
<span class="truncate">{label()}</span>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-72 max-w-[calc(100vw-24px)]" aria-label={label()}>
<Show when={props.subagents.length > 0}>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.activity.section.subagents")}</Menu.GroupLabel>
<For each={props.subagents}>
{(item) => (
<Menu.Item onSelect={() => props.onOpen(item)}>
<SessionActivityGlyph class="text-v2-icon-icon-muted" />
<span class="shrink-0 font-[530]">
{item.agent
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
: language.t("ui.tool.agent.default")}
</span>
<span dir="auto" class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted">
{item.label}
</span>
</Menu.Item>
)}
</For>
</Menu.Group>
</Show>
<Show when={props.subagents.length > 0 && props.shells.length > 0}>
<Menu.Separator />
</Show>
<Show when={props.shells.length > 0}>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.activity.section.shells")}</Menu.GroupLabel>
<For each={props.shells}>
{(item) => (
<Menu.Item onSelect={() => props.onOpen(item)}>
<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />
<span dir="ltr" class="me-1.5 min-w-0 flex-1 truncate font-mono text-v2-text-text-base">
{item.label}
</span>
</Menu.Item>
)}
</For>
</Menu.Group>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</Show>
)
}
// A 5×5 dot matrix with a diagonal wave, ported from the iOS app's AgentActivityGlyph.
function SessionActivityGlyph(props: { class?: string }) {
// Glyphs mounted at different times share one phase, like the iOS glyph's clock-driven wave.
const phase = `${-(performance.now() % 1100)}ms`
return (
<svg
data-component="session-activity-glyph"
class={props.class}
width={15}
height={15}
viewBox="0 0 5 5"
aria-hidden="true"
style={{ "--session-activity-glyph-phase": phase }}
>
<Index each={Array.from({ length: 25 })}>
{(_, index) => (
<circle
data-dot
cx={(index % 5) + 0.5}
cy={Math.floor(index / 5) + 0.5}
r={0.31}
style={{ "--diagonal": (index % 5) + Math.floor(index / 5) }}
/>
)}
</Index>
<circle data-reduced-motion cx={2.5} cy={2.5} r={0.75} />
</svg>
)
}
@@ -29,6 +29,8 @@ import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
import { createSessionComposerController, type SessionComposerController } from "./controller"
import { SessionQueuePanel } from "./queue-panel"
import { SessionActivityChips, type SessionActivityItem } from "./activity-chips"
import { useOpenSessionRoute } from "../session-identity-header"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import type { Region } from "@/runtime/extension/panels"
@@ -295,6 +297,8 @@ export function ActiveSessionComposerRegion(props: {
suggestionBoundary: () => HTMLElement | undefined
}) {
const location = useWorkspaceLocation()
const data = useData()
const openRoute = useOpenSessionRoute()
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
const missing = createMemo(() => {
@@ -306,6 +310,51 @@ export function ActiveSessionComposerRegion(props: {
return { directory: info.location.directory, sessionID: info.id, projectID: info.projectID }
})
// Subagents count while their call still blocks the run; foreground shells stay out, the timeline shows them.
const subagents = createMemo<SessionActivityItem[]>(() => [
...props.model.region.state.background.blocking().flatMap((task) =>
task.type === "subagent"
? [
{
...task,
key: task.id ?? task.partID,
agent: task.id ? data.session.get(task.id)?.agent : undefined,
sessionID: task.id,
target: task.partID,
},
]
: [],
),
...props.model.region.state.background.tasks().flatMap((task) =>
task.type === "subagent"
? [
{
...task,
key: task.id,
agent: task.agent ?? data.session.get(task.id)?.agent,
sessionID: task.id,
target: task.id,
},
]
: [],
),
])
const shells = createMemo<SessionActivityItem[]>(() =>
props.model.region.state.background
.tasks()
.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
)
const open = (item: SessionActivityItem) => {
const current = props.session.identity.params.id
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)
}
return (
<SessionComposerRegion
controller={props.model.region}
@@ -315,6 +364,7 @@ export function ActiveSessionComposerRegion(props: {
keyed
fallback={
<div class="relative">
<SessionActivityChips subagents={subagents()} shells={shells()} onOpen={open} />
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
+33 -4
View File
@@ -6,9 +6,11 @@ import {
SessionComposerRegion,
type SessionComposerRegionViewController,
} from "@/session/composer/session-composer-region"
import { SessionActivityChips, type SessionActivityItem } from "@/session/composer/activity-chips"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode/client/promise"
import type { SessionDocument } from "@opencode/session-ui/document"
import type { TimelineDetail } from "@opencode/session-ui/timeline/detail"
import { CurrentSessionProviders, STORY_MODEL } from "@opencode/session-ui/storybook"
import { SessionTimeline } from "@opencode/session-ui/timeline"
import { createComposerEditor } from "@/composer/editor/interaction"
@@ -85,6 +87,12 @@ export type SessionPreviewProps = {
reviewOpened?: boolean
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
activity?: {
subagents: SessionActivityItem[]
shells: SessionActivityItem[]
onOpen?: (item: SessionActivityItem) => void
}
timelineDetail?: TimelineDetail
}
export function SessionPreview(props: SessionPreviewProps) {
@@ -187,7 +195,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
const prompt = createPromptController({
initial: props.draft ?? "",
placeholder: language.t("prompt.placeholder.normal"),
placeholder: language.t(
props.document.status.type === "idle"
? "ui.promptInput.placeholder.normal"
: "ui.promptInput.placeholder.followUp",
{ slash: "/", at: "@" },
),
status: () => props.document.status,
onActivity: (activity) => setState("activity", activity),
onSubmit: (text) => setState("activity", `Submitted locally: ${text}`),
@@ -256,14 +269,15 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
<div class="flex min-h-0 flex-1">
<section
classList={{
"min-w-0 flex-1 flex-col bg-background-base": true,
"min-w-0 flex-1 flex-col bg-v2-background-bg-base": true,
flex: !state.reviewOpened,
"hidden md:flex": state.reviewOpened,
}}
>
<div class="min-h-0 flex-1 overflow-y-auto py-6">
<div data-slot="story-timeline" class="min-h-0 flex-1 overflow-y-auto py-6">
<SessionTimeline
document={props.document}
timelineDetail={props.timelineDetail}
editToolDefaultOpen
shellToolDefaultOpen
class="mx-auto w-full max-w-[840px]"
@@ -271,7 +285,22 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
</div>
<SessionComposerRegion
controller={region}
composer={<Composer model={prompt.controller} borderUnderlay />}
composer={
<div class="relative">
<SessionActivityChips
subagents={props.activity?.subagents ?? []}
shells={props.activity?.shells ?? []}
onOpen={(item) =>
props.activity?.onOpen
? props.activity.onOpen(item)
: setState("activity", `Opened ${item.label ?? item.key}`)
}
/>
<div class="relative z-10">
<Composer model={prompt.controller} borderUnderlay />
</div>
</div>
}
/>
</section>
<Show when={state.reviewOpened}>