mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): keep the activity pill and busy indicators steady when switching sessions (#57196)
This commit is contained in:
committed by
GitHub
Unverified
parent
7920dfcb4b
commit
d3d96a3ad1
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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 (
|
||||
<Show when={props.subagents.length > 0 || props.shells.length > 0}>
|
||||
<Show when={active()}>
|
||||
<div data-component="session-activity-chips" class="pointer-events-none flex items-center pb-2">
|
||||
<Menu gutter={6} placement="top-start">
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-slot="session-activity-chip"
|
||||
class="pointer-events-auto 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"
|
||||
class="pointer-events-auto 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 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"
|
||||
classList={{ "animate-in fade-in": enter() }}
|
||||
>
|
||||
<SessionProgressIndicatorV2 class="shrink-0" />
|
||||
<span class="truncate">{label()}</span>
|
||||
@@ -137,4 +146,3 @@ export function SessionActivityChips(props: {
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 <image>: Chromium restarts an animated image
|
||||
// shown by <img> or <image> 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 (
|
||||
<svg
|
||||
@@ -29,25 +19,6 @@ export function SessionProgressIndicatorV2(props: ComponentProps<"svg">) {
|
||||
data-component="session-progress-indicator-v2"
|
||||
aria-hidden={rest["aria-hidden"] ?? "true"}
|
||||
>
|
||||
<defs>
|
||||
<filter id={filter} filterUnits="userSpaceOnUse" x={0} y={0} width={16} height={16}>
|
||||
<feFlood flood-color="currentColor" result="color" />
|
||||
<feComposite in="color" in2="SourceAlpha" operator="in" />
|
||||
</filter>
|
||||
<clipPath id={clip}>
|
||||
<path d={dots} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<image
|
||||
data-frame-content
|
||||
href={frames}
|
||||
x={0}
|
||||
y={0}
|
||||
width={16}
|
||||
height={16}
|
||||
filter={`url(#${filter})`}
|
||||
clip-path={`url(#${clip})`}
|
||||
/>
|
||||
<rect data-reduced-motion x={7} y={7} width={2} height={2} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user