From 838b8f10a0d6e1491147b34ff745ff19101e83dc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 18 Oct 2026 16:39:02 -0400 Subject: [PATCH] feat(app): show background activity capsule above composer --- packages/app/src/runtime/i18n/en.ts | 7 + packages/app/src/runtime/i18n/language.tsx | 11 + .../src/session/composer/activity-chips.css | 65 +++++ .../composer/activity-chips.stories.tsx | 240 ++++++++++++++++++ .../src/session/composer/activity-chips.tsx | 116 +++++++++ packages/app/src/session/composer/region.tsx | 50 ++++ packages/app/src/session/story-model.tsx | 37 ++- 7 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/session/composer/activity-chips.css create mode 100644 packages/app/src/session/composer/activity-chips.stories.tsx create mode 100644 packages/app/src/session/composer/activity-chips.tsx diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index a0122fe74b5..63b40c44456 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/language.tsx b/packages/app/src/runtime/i18n/language.tsx index fed6514a852..acdf98b4f8d 100644 --- a/packages/app/src/runtime/i18n/language.tsx +++ b/packages/app/src/runtime/i18n/language.tsx @@ -277,6 +277,16 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont const plural = (key: PluralKey, count: number, params?: Record) => 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: TranslationKey, source: string, @@ -326,6 +336,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont tDynamic, plural, pluralForm, + activitySummary, rich, list, setLocale(next: Locale) { diff --git a/packages/app/src/session/composer/activity-chips.css b/packages/app/src/session/composer/activity-chips.css new file mode 100644 index 00000000000..79676d4412d --- /dev/null +++ b/packages/app/src/session/composer/activity-chips.css @@ -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; + } +} diff --git a/packages/app/src/session/composer/activity-chips.stories.tsx b/packages/app/src/session/composer/activity-chips.stories.tsx new file mode 100644 index 00000000000..9d4ed68d4a4 --- /dev/null +++ b/packages/app/src/session/composer/activity-chips.stories.tsx @@ -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 ( + 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 ( + 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('[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: () => } diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx new file mode 100644 index 00000000000..5138f6c824a --- /dev/null +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -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 ( + 0 || props.shells.length > 0}> +
+ + + + {label()} + + + + 0}> + + {language.t("session.activity.section.subagents")} + + {(item) => ( + props.onOpen(item)}> + + + {item.agent + ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` + : language.t("ui.tool.agent.default")} + + + {item.label} + + + )} + + + + 0 && props.shells.length > 0}> + + + 0}> + + {language.t("session.activity.section.shells")} + + {(item) => ( + props.onOpen(item)}> + + + {item.label} + + + )} + + + + + + +
+
+ ) +} + +// 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 ( + + ) +} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 072f61caf18..8aefbe4a650 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -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(() => [ + ...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(() => + 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 ( +
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
-
+
void
} + composer={ +
+ + props.activity?.onOpen + ? props.activity.onOpen(item) + : setState("activity", `Opened ${item.label ?? item.key}`) + } + /> +
+ +
+
+ } />