mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Make the accent shimmer contrasty and robust to mid-turn attach
Contrast: the accent shimmer band swept raw primary, which sits too close to the text's lightness in both themes (58%-L purple vs near-black text in light; 80%-L purple vs near-white text in dark) and barely read. The band now mixes primary toward the background — lighter in light mode, darker in dark mode, both directions pulling away from the text — and narrows so the highlight covers more visible text per pass. Robustness: observer frames are ephemeral, so an app subscribing mid-turn missed turn_started and the turns store stayed empty (dark sidebar shimmer, no working badges) until a liveness ping happened to land. Any frame carrying a turn context — tool calls included, which stream constantly during work — now establishes the turn, still gated by the terminal tombstone so completed turns stay dead. Adds an e2e spec covering the sidebar title shimmer end to end via seeded active turns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
463c74d5a2
commit
ba8ac20fec
@@ -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, [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user