From d3d96a3ad18fb9ce35f2dc8f7c25a2e12e6c168f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 19 Oct 2026 14:07:39 -0700 Subject: [PATCH] fix(app): keep the activity pill and busy indicators steady when switching sessions (#57196) --- packages/app/e2e/regression/tabs.spec.ts | 50 +++++++++++++++++++ .../src/session/composer/activity-chips.tsx | 16 ++++-- .../session-progress-indicator-v2.css | 16 ++++-- .../session-progress-indicator-v2.tsx | 35 ++----------- 4 files changed, 76 insertions(+), 41 deletions(-) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 01c20b7822f..17908249d01 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -705,6 +705,56 @@ test("inactive tabs stay busy while work waits in their inbox, and pulse on a ne await expect(tab(a.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0) }) +test("switching between busy sessions keeps the busy indicators and the activity capsule at rest", async ({ page }) => { + const children = [ + { id: "ses_tab_a_child", title: "Map the orchard", parentID: a.id }, + { id: "ses_tab_b_child", title: "Chart the harbor", parentID: b.id }, + ] + + await mockWorkspace(page, { + name: "TabActivity", + sessions: [a, b, ...children], + sessionStatus: Object.fromEntries(children.map((child) => [child.id, { type: "running" as const }])), + seed: { settings: { appearance: { tabLayout: "vertical" } }, tabs: [a.id, b.id] }, + }) + await page.goto(sessionHref(a.id)) + await expectSessionTitle(page, a.title) + + const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]') + const indicators = sidebar.locator('[data-component="session-progress-indicator-v2"]') + const capsule = page.locator('[data-slot="session-activity-chip"]') + await expect(indicators).toHaveCount(2) + await expect(capsule).toHaveText("1 subagent") + await expect(capsule).toHaveCSS("opacity", "1") + + // Record any capsule animation or any moment without a capsule, and mark the indicators to compare them later. + await page.evaluate(() => { + const record = document.documentElement + document.addEventListener("animationstart", (event) => { + if (event.target instanceof Element && event.target.closest('[data-slot="session-activity-chip"]')) + record.setAttribute("data-test-capsule-animated", "") + }) + new MutationObserver(() => { + if (!document.querySelector('[data-slot="session-activity-chip"]')) + record.setAttribute("data-test-capsule-missing", "") + }).observe(document.body, { childList: true, subtree: true }) + document + .querySelectorAll('[data-slot="vertical-tabs-sidebar"] [data-component="session-progress-indicator-v2"]') + .forEach((element) => element.setAttribute("data-test-before-switch", "")) + }) + + await sidebar.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`).click() + await expectSessionTitle(page, b.title) + await expect(page.locator('[data-component="session-activity-chips"]')).toHaveCount(1) + await expect(capsule).toHaveText("1 subagent") + await capsule.click() + await expect(page.locator('[data-slot="session-activity-panel"]')).toContainText(children[1]!.title) + + await expect(indicators.and(page.locator("[data-test-before-switch]"))).toHaveCount(2) + await expect(page.locator("html")).not.toHaveAttribute("data-test-capsule-missing") + await expect(page.locator("html")).not.toHaveAttribute("data-test-capsule-animated") +}) + test("selecting a tab with waiting work waits for its transcript instead of showing only the inbox", async ({ page, }) => { diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index 21cd10976bb..c0fa1987a53 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -2,7 +2,7 @@ import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" -import { For, Show } from "solid-js" +import { createEffect, createSignal, For, Show } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import "./activity-chips.css" @@ -66,16 +66,25 @@ export function SessionActivityChips(props: { ) const label = () => language.activitySummary({ subagents: props.subagents.length, shells: props.shells.length }) + const active = () => props.subagents.length > 0 || props.shells.length > 0 + + // Each session mounts its own composer, so a capsule that is already there on a switch stays at rest; only work + // that starts while the session is open fades in. + const [enter, setEnter] = createSignal(!active()) + createEffect(() => { + if (!active()) setEnter(true) + }) return ( - 0 || props.shells.length > 0}> +
{label()} @@ -137,4 +146,3 @@ export function SessionActivityChips(props: { ) } - diff --git a/packages/session-ui/src/v2/components/session-progress-indicator-v2.css b/packages/session-ui/src/v2/components/session-progress-indicator-v2.css index d320eb01de6..8d053fb8a20 100644 --- a/packages/session-ui/src/v2/components/session-progress-indicator-v2.css +++ b/packages/session-ui/src/v2/components/session-progress-indicator-v2.css @@ -2,9 +2,14 @@ display: block; flex-shrink: 0; color: var(--v2-icon-icon-muted, #808080); -} - -[data-component="session-progress-indicator-v2"] [data-frame-content] { + background-color: currentColor; + /* The animated frames carry each dot's opacity; the grid keeps the gaps between dots. */ + mask-image: + url("./session-progress-indicator-v2-1x.png"), + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 1h2v2h-2zM4 1h2v2h-2zM7 1h2v2h-2zM10 1h2v2h-2zM13 1h2v2h-2zM1 4h2v2h-2zM4 4h2v2h-2zM7 4h2v2h-2zM10 4h2v2h-2zM13 4h2v2h-2zM1 7h2v2h-2zM4 7h2v2h-2zM7 7h2v2h-2zM10 7h2v2h-2zM13 7h2v2h-2zM1 10h2v2h-2zM4 10h2v2h-2zM7 10h2v2h-2zM10 10h2v2h-2zM13 10h2v2h-2zM1 13h2v2h-2zM4 13h2v2h-2zM7 13h2v2h-2zM10 13h2v2h-2zM13 13h2v2h-2z'/%3E%3C/svg%3E"); + mask-size: 100% 100%; + mask-repeat: no-repeat; + mask-composite: intersect; image-rendering: pixelated; image-rendering: crisp-edges; } @@ -15,8 +20,9 @@ } :root[data-motion="reduce"] { - [data-component="session-progress-indicator-v2"] [data-frame-content] { - display: none; + [data-component="session-progress-indicator-v2"] { + background-color: transparent; + mask-image: none; } [data-component="session-progress-indicator-v2"] [data-reduced-motion] { diff --git a/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx b/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx index 5399597dc84..c1a1a6cdec3 100644 --- a/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx +++ b/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx @@ -1,20 +1,10 @@ -import { createUniqueId, splitProps, type ComponentProps } from "solid-js" +import { splitProps, type ComponentProps } from "solid-js" import "./session-progress-indicator-v2.css" -const frames = new URL("./session-progress-indicator-v2-1x.png", import.meta.url).href - -const dots = Array.from({ length: 25 }, (_, index) => { - const x = 1 + (index % 5) * 3 - const y = 1 + Math.floor(index / 5) * 3 - - return `M${x} ${y}h2v2h-2z` -}).join("") - +// The frames paint through a CSS mask (see the stylesheet), not an SVG : Chromium restarts an animated image +// shown by or for every instance whenever another one mounts, while CSS images join the running loop. export function SessionProgressIndicatorV2(props: ComponentProps<"svg">) { const [local, rest] = splitProps(props, ["class", "classList", "width", "height"]) - const id = createUniqueId() - const filter = `session-progress-indicator-filter-${id}` - const clip = `session-progress-indicator-clip-${id}` return ( ) { data-component="session-progress-indicator-v2" aria-hidden={rest["aria-hidden"] ?? "true"} > - - - - - - - - - - )