diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 75d216e2e..7212d4a44 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -1107,6 +1107,52 @@ describe("activeAgentTurnsStore", () => { ); }); + it("establishes a turn from a tool frame when turn_started was never seen", () => { + // Observer frames are ephemeral: an app subscribing mid-turn missed + // turn_started. The first in-turn frame of ANY kind must establish the + // turn so working indicators light up immediately. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 1, + kind: "tool_call", + turnId: "t1", + channelId: "c1", + timestamp: at(0), + }), + ]); + assert.ok( + channelIdsOf(getActiveTurnsForAgent(AGENT)).has("c1"), + "a mid-turn tool frame must establish the turn without turn_started", + ); + }); + + it("does NOT establish a turn from a tool frame older than its completion", () => { + // The tombstone gate applies to the establish path too: frames from a + // turn that already terminally ended must not revive it. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }), + makeEvent({ + seq: 2, + kind: "turn_completed", + turnId: "t1", + channelId: "c1", + timestamp: at(10_000), + }), + makeEvent({ + seq: 3, + kind: "tool_call", + turnId: "t1", + channelId: "c1", + timestamp: at(8_000), + }), + ]); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "a stale tool frame must not revive a completed turn", + ); + }); + it("does NOT resurrect from a liveness frame with no channelId", () => { // A pruned turn cannot be rebuilt without a channelId to anchor the badge. syncAgentTurnsFromEvents(AGENT, [ diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 08a58170a..020109a01 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -354,12 +354,23 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { return; case "acp_read": case "acp_write": + case "turn_liveness": + // Any other frame carrying a turn context (tool calls, assistant text, + // …) is equally hard evidence the turn is alive. Observer frames are + // ephemeral — an app that subscribes mid-turn missed `turn_started` + // entirely — so every in-turn frame must be able to establish the turn, + // not just refresh it. Without this, a chat can stream visible activity + // while the working indicators (sidebar shimmer, badges) stay dark + // until the next liveness ping happens to land. + // // turn_liveness keeps a quiet-but-alive turn from being pruned; same - // refresh-only path as stream activity — no surfaced summary change on its - // own, so it only notifies when the offset above actually moved. If the - // turn was pruned out from under a still-running host (a transient drop - // raced the pause, or the lone-crash residual self-healed), resurrect it. - case "turn_liveness": { + // refresh-only path as stream activity — no surfaced summary change on + // its own, so it only notifies when the offset above actually moved. If + // the turn was pruned out from under a still-running host (a transient + // drop raced the pause, or the lone-crash residual self-healed), or was + // never seen to start, resurrect it. The terminal tombstone still keeps + // frames from a completed turn (or stale replays) from reviving it. + default: { const refreshed = recordActivity(agentPubkey, event.turnId ?? null); if (!refreshed && resurrectTurn(agentPubkey, event)) { notifyListeners(); diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index cca99cf1b..028c47697 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -211,9 +211,22 @@ /* Accent variant: keeps the text at its normal color and sweeps a primary-colored band across it, for places (sidebar titles) where the - default dimmed-base treatment reads as a de-emphasized row. */ + default dimmed-base treatment reads as a de-emphasized row. + + The band must contrast with the TEXT it sweeps (full foreground — this + variant never dims), so the primary hue is pulled toward the background: + lighter in light mode (against near-black text), darker in dark mode + (against near-white text). Raw hsl(var(--primary)) sat too close to the + text's lightness in both themes and the sweep barely read. The narrower + band (300% vs 400%) also makes the highlight cover more of the visible + text per pass. */ .buzz-shimmer-accent { - --buzz-shimmer-highlight: hsl(var(--primary)); + --buzz-shimmer-highlight: color-mix( + in oklab, + hsl(var(--primary)) 55%, + hsl(var(--background)) + ); + --buzz-shimmer-band: 300%; color: inherit; } diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 6a0543e51..3c8f83389 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -174,3 +174,58 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { await waitForAnimations(page); await page.screenshot({ path: "test-results/agent-pr-card.png" }); }); + +test("sidebar chat title shimmers while the agent has an active turn", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/#/chats"); + + const composer = page.locator("[contenteditable='true'], textarea").first(); + await expect(composer).toBeVisible(); + await composer.click(); + await composer.fill("Spin up a new worktree to look at the panel"); + await composer.press("Enter"); + await expect(page).toHaveURL(/\/chats\/.+/); + const chatId = decodeURIComponent( + new URL(page.url().replace("/#/", "/")).pathname.split("/").at(-1) ?? "", + ); + expect(chatId.length).toBeGreaterThan(0); + + // Quiet agent: no shimmer on the sidebar title. + const shimmerTitle = page.locator( + "[data-shimmer-text*='Spin up a new worktree']", + ); + await expect(shimmerTitle).toHaveCount(0); + + // Seed an active turn for this chat — the row title must pick up the + // accent shimmer (class + animated overlay), same store the working row + // reads. + await page.evaluate( + ({ channelId }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: "ab".repeat(32), + channelId, + turnId: "turn-shimmer-1", + }); + }, + { channelId: chatId }, + ); + + await expect(shimmerTitle).toHaveCount(1); + await expect(shimmerTitle).toHaveClass(/buzz-shimmer/); + await expect(shimmerTitle).toHaveClass(/buzz-shimmer-accent/); + expect( + await shimmerTitle.evaluate( + (element) => window.getComputedStyle(element, "::before").animationName, + ), + ).toBe("buzz-shimmer"); +});