fix: restore automation sidebar running indicator (#3064)

This commit is contained in:
Carlos Florêncio
2026-08-26 21:05:26 +00:00
committed by GitHub
parent 37e0906490
commit efe3cabaaf
9 changed files with 389 additions and 8 deletions
@@ -2,6 +2,7 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Automation } from "@/lib/types/automation";
import { LIVE_REFRESH_INTERVAL_MS } from "@/components/runs/use-live-refresh";
const WORKSPACE_ID = "workspace-1";
const AUTOMATION_ID = "automation-1";
@@ -12,6 +13,7 @@ const mocks = vi.hoisted(() => ({
listAutomationSummaries: vi.fn(),
activeWorkspaceId: { current: "workspace-1" as string | undefined },
sectionExpanded: { current: {} as Record<string, boolean> },
responsive: { isMobile: false },
toggleSection: vi.fn(),
setCollapsed: vi.fn(),
}));
@@ -43,6 +45,10 @@ vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => mocks.responsive,
}));
import { AutomationsSection } from "./automations-section";
function mkAutomation(overrides: Partial<Automation> = {}): Automation {
@@ -77,6 +83,9 @@ function renderOpenSection() {
beforeEach(() => {
mocks.activeWorkspaceId.current = WORKSPACE_ID;
mocks.sectionExpanded.current = {};
mocks.responsive.isMobile = false;
mocks.listAutomations.mockReset();
mocks.listAutomationSummaries.mockReset();
mocks.listAutomations.mockResolvedValue([mkAutomation()]);
mocks.listAutomationSummaries.mockResolvedValue([]);
});
@@ -223,6 +232,71 @@ describe("AutomationsSection", () => {
});
});
describe("AutomationsSection live running state", () => {
it("refreshes a visible row and swaps its indicator as the run state changes", async () => {
mocks.listAutomationSummaries
.mockResolvedValueOnce([{ automation_id: AUTOMATION_ID, open_runs: 0 }])
.mockResolvedValueOnce([{ automation_id: AUTOMATION_ID, open_runs: 1 }])
.mockResolvedValueOnce([
{
automation_id: AUTOMATION_ID,
open_runs: 0,
last_run: {
id: "run-1",
automation_id: AUTOMATION_ID,
status: "succeeded",
created_at: new Date().toISOString(),
},
},
]);
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
renderOpenSection();
await screen.findByTestId(`sidebar-automation-${AUTOMATION_ID}`);
await waitFor(() => expect(mocks.listAutomationSummaries).toHaveBeenCalledTimes(1));
await act(async () => {
await vi.advanceTimersByTimeAsync(LIVE_REFRESH_INTERVAL_MS);
});
const runningIndicator = await screen.findByTestId(
`sidebar-automation-running-${AUTOMATION_ID}`,
);
expect(runningIndicator.classList).toContain("animate-spin");
expect(runningIndicator.getAttribute("aria-hidden")).toBe("true");
expect(screen.getByText("Running.")).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(LIVE_REFRESH_INTERVAL_MS);
});
await waitFor(() => expect(mocks.listAutomationSummaries).toHaveBeenCalledTimes(3));
expect(screen.queryByTestId(`sidebar-automation-running-${AUTOMATION_ID}`)).toBeNull();
expect(screen.getByText("Idle.")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
});
describe("AutomationsSection responsive visibility", () => {
it("does not fetch or poll health while the desktop rail is hidden on mobile", async () => {
mocks.responsive.isMobile = true;
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
renderOpenSection();
await screen.findByTestId(`sidebar-automation-${AUTOMATION_ID}`);
await act(async () => {
await vi.advanceTimersByTimeAsync(LIVE_REFRESH_INTERVAL_MS * 2);
});
expect(mocks.listAutomationSummaries).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("AutomationsSection empty state", () => {
it("keeps an empty section folded until the user opens it", () => {
mocks.listAutomations.mockResolvedValue([]);
@@ -1,7 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { IconBolt, IconListDetails } from "@tabler/icons-react";
import { IconBolt, IconListDetails, IconLoader2 } from "@tabler/icons-react";
import Link from "@/components/routing/app-link";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
@@ -12,9 +12,11 @@ import {
} from "@/components/runs/automation-rows";
import type { AutomationRow } from "@/components/runs/automation-rows";
import { useAutomationSummaries } from "@/components/runs/use-automation-summaries";
import { useLiveRefresh } from "@/components/runs/use-live-refresh";
import { useWorkspaceAutomations } from "@/components/runs/use-workspace-automations";
import { AUTOMATIONS_HREF } from "@/components/runs/runs-view";
import { usePathname } from "@/lib/routing/client-router";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useNow } from "@/hooks/use-now";
import { cn, formatRelativeTime } from "@/lib/utils";
import {
@@ -78,10 +80,18 @@ function AutomationRowLink({ row, active }: { row: AutomationRow; active: boolea
active ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE,
)}
>
<span
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", STATE_DOT_CLASS[state])}
aria-hidden="true"
/>
{state === "running" ? (
<IconLoader2
className="h-3.5 w-3.5 shrink-0 animate-spin text-blue-500"
aria-hidden="true"
data-testid={`sidebar-automation-running-${automation.id}`}
/>
) : (
<span
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", STATE_DOT_CLASS[state])}
aria-hidden="true"
/>
)}
{/* The dot is decorative, so the state reaches a screen reader here. */}
<span className="sr-only">{`${t(STATE_LABEL_KEY[state])}.`}</span>
<span className="min-w-0 flex-1 truncate">{automation.name}</span>
@@ -118,6 +128,7 @@ export function AutomationsSection({ collapsed }: { collapsed: boolean }) {
const { t } = useTranslation();
const pathname = usePathname();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const { isMobile } = useResponsiveBreakpoint();
// Folded until asked for. Automations are a background concern — they run
// whether or not anyone is looking — so they should not push the tasks
// someone came here to work on off the bottom of the rail.
@@ -130,11 +141,12 @@ export function AutomationsSection({ collapsed }: { collapsed: boolean }) {
// has to say how many it is hiding — a section that starts shut and shows
// nothing reads as empty, and nobody opens it. Health summaries are the
// heavier read and buy nothing until the rows themselves are on screen.
const showing = !collapsed && expanded;
const showing = !collapsed && expanded && !isMobile;
const listScope = collapsed ? undefined : (workspaceId ?? undefined);
const summaryScope = showing ? (workspaceId ?? undefined) : undefined;
const { automations } = useWorkspaceAutomations(listScope);
const { summaries } = useAutomationSummaries(summaryScope);
const { summaries, refresh } = useAutomationSummaries(summaryScope);
useLiveRefresh(showing, refresh);
const rows = buildAutomationRows(automations, summaries);
return (
@@ -75,6 +75,23 @@ describe("useAutomationSummaries", () => {
expect(result.current.loading).toBe(false);
});
it("keeps the last successful summaries when a refresh fails", async () => {
mockList
.mockResolvedValueOnce([mkSummary(A1, 1)])
.mockRejectedValueOnce(new Error(SOCKET_CLOSED));
const { result } = renderHook(() => useAutomationSummaries(WORKSPACE));
await waitFor(() => expect(result.current.summaries[0]?.open_runs).toBe(1));
await act(async () => {
result.current.refresh();
});
await waitFor(() => expect(result.current.error).toBe(SOCKET_CLOSED));
expect(result.current.summaries).toEqual([mkSummary(A1, 1)]);
});
it("clears a previous workspace's error when switching", async () => {
mockList.mockRejectedValueOnce(new Error(SOCKET_CLOSED));
const { result, rerender } = renderHook(({ ws }) => useAutomationSummaries(ws), {
@@ -42,7 +42,11 @@ export function useAutomationSummaries(workspaceId: string | undefined) {
})
.catch((err: unknown) => {
if (requestRef.current !== requestId) return;
setLoaded({ workspaceId, summaries: EMPTY_SUMMARIES });
setLoaded((current) =>
current.workspaceId === workspaceId
? current
: { workspaceId, summaries: EMPTY_SUMMARIES },
);
setError(
err instanceof Error ? err.message : t("automations:failedToLoadAutomationActivity"),
);
@@ -1,6 +1,7 @@
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
import type { ApiClient } from "../helpers/api-client";
import { watchWs } from "../helpers/causal-waits";
/**
* The Automations section of the app sidebar.
@@ -121,4 +122,41 @@ test.describe("Automations section in the app sidebar", () => {
await expect(testPage.getByTestId(`sidebar-automation-${neverRan.id}`)).toBeVisible();
await expect(testPage.getByTestId(`sidebar-automation-last-run-${neverRan.id}`)).toHaveCount(0);
});
test("refreshes an open row when a run starts without navigation", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const [automation] = await seedAutomations(apiClient, seedData, ["Nightly drift"]);
const ws = watchWs(testPage);
await testPage.goto("/");
const header = automationsHeader(testPage);
await expect(header).toBeVisible({ timeout: 15_000 });
const initialSummary = ws.waitForResponse("automation.summaries");
await header.click();
await initialSummary;
const row = testPage.getByTestId(`sidebar-automation-${automation.id}`);
await expect(row).toBeVisible();
await expect(row.getByTestId(`sidebar-automation-running-${automation.id}`)).toHaveCount(0);
const urlBeforeRun = testPage.url();
const liveSummary = ws.waitForResponse("automation.summaries");
await apiClient.seedAutomationRun(automation.id, "triggered");
await liveSummary;
const runningIndicator = row.getByTestId(`sidebar-automation-running-${automation.id}`);
await expect(runningIndicator).toBeVisible();
await expect(runningIndicator).toHaveClass(/animate-spin/);
await expect(runningIndicator).toHaveAttribute("aria-hidden", "true");
await expect(row.getByText("Running.")).toBeAttached();
await expect(testPage).toHaveURL(urlBeforeRun);
await prCapture.screenshot("automation-sidebar-running", {
caption: "Expanded desktop Automations sidebar with a running automation indicator",
});
});
});
@@ -0,0 +1,118 @@
---
created: 2026-08-26
status: done
requirements:
- REQ-OFFICE-AUTOMATION-RUNS-001
system_design:
- ../../specs/office/system-design/automation-runs.md
legacy_specs: []
---
# Implementation Plan: Automation Sidebar Running Indicator
## Overview
Restore live automation health in the visible desktop sidebar and make an open
run visually unmistakable. The change remains frontend-only: the sidebar will
reuse the guarded automation-summary request and established live-refresh
cadence, then render a spinner for the existing `running` state. Focused unit
and Playwright coverage will prove the idle-to-running transition without a
reload.
## Scope
### In scope
- Refresh automation health summaries while the expanded Automations section
is visible.
- Stop health refreshes while the section or desktop rail is hidden.
- Replace the running health dot with an animated loader while preserving the
localized screen-reader state.
- Prove the reported recurring-run case through component and desktop browser
coverage.
### Out of scope
- New backend events or WebSocket actions.
- Scheduler timing, automation admission, run persistence, or concurrency-cap
changes.
- Changes to the `/automations` agenda or detail views.
- Adding the desktop-only Automations rail section to phone navigation.
## Technical approach
### Visible summary refresh
- In
`apps/web/components/app-sidebar/sections/automations-section.tsx`, retain the
full `useAutomationSummaries` result and pass its stable `refresh` function to
`useLiveRefresh` with `showing` as the activity gate.
- Keep the existing `summaryScope` gate. Initial reads and repeat reads occur
only while the section is expanded in the non-collapsed desktop rail; the
hook cleanup stops the timer when that condition changes.
- Reuse `useAutomationSummaries` request ordering rather than adding local
response arbitration or another polling abstraction.
### Running indicator
- In `AutomationRowLink`, render a compact Tabler loader with `animate-spin`
and a stable automation-scoped test ID when `row.state === "running"`.
- Keep `STATE_DOT_CLASS` for `idle` and `paused`. Preserve the current
screen-reader-only `STATE_LABEL_KEY` text, so the running animation is
decorative and the semantic state remains localized.
- Keep the row height, navigation target, title truncation, trailing last-run
age, and pointer behavior unchanged.
### Mobile parity
`AppSidebar` is explicitly desktop-only (`hidden md:block`); phone navigation
does not render `AutomationsSection`. This is a compact status-content change,
not a navigation or touch change. The closest mobile visual precedent is the
shared task-switcher running indicator, which also pairs a compact animated
state with semantic text. No phone composition, action, scroll owner, safe-area
rule, or touch target changes, so mobile Playwright coverage is not added for
this desktop-only surface.
## Tests
- `apps/web/components/app-sidebar/sections/automations-section.test.tsx`
covers `AC-OFFICE-AUTOMATION-RUNS-001.9`: an initially idle row re-reads its
summary on the live-refresh cadence, changes to an animated running indicator,
and returns to a non-running dot after the terminal summary arrives.
- The same suite asserts that a running row exposes the localized `Running.`
text, that only the loader animates, and that folded/collapsed states do not
issue health-summary requests.
## E2E tests
- Extend `apps/web/e2e/tests/automations-sidebar.spec.ts` for
`AC-OFFICE-AUTOMATION-RUNS-001.9`: mount and open the sidebar while an
automation is idle, seed an open run through the existing E2E API, wait for
the causally matched `automation.summaries` WebSocket response, and assert
that the same row shows the animated indicator without navigation or reload.
- Run the spec in the `chromium` project. The component is not mounted by the
`mobile-chrome` layout, and the change does not alter any shared mobile
component.
## Work orders
- [x] [Task 01: Restore the live sidebar indicator](task-01-live-sidebar-indicator.md)
## Verification results
- PASS `pnpm --filter @kandev/web exec vitest run components/app-sidebar/sections/automations-section.test.tsx components/runs/use-live-refresh.test.ts` (22 tests).
- PASS fixup regression suite covering the responsive visibility and refresh-error cases with the same targets plus `components/runs/use-automation-summaries.test.ts` (30 tests).
- PASS `pnpm --filter @kandev/web run typecheck`.
- PASS targeted ESLint for the changed sidebar component, component test, summary hook, and summary-hook test.
- PASS `pnpm e2e:run --project chromium tests/automations-sidebar.spec.ts` (4 tests).
- PASS `python3 scripts/lint-spec-files.py --all`.
## Risks
- The E2E stimulus must occur after the initial summary response so the next
causally matched response represents the live refresh, not first load.
- A visible idle section will now perform the same 10-second summary read that
running automation pages already use. The query is workspace-bounded and the
timer is disabled whenever the section is not visible.
- The visual branch must not remove the localized state text or change the row
geometry.
@@ -0,0 +1,102 @@
---
id: "01-live-sidebar-indicator"
title: "Restore the live sidebar indicator"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-OFFICE-AUTOMATION-RUNS-001
acceptance_criteria:
- AC-OFFICE-AUTOMATION-RUNS-001.9
system_design:
- ../../specs/office/system-design/automation-runs.md
---
# Task 01: Restore the Live Sidebar Indicator
## Summary
Make the visible Automations sidebar section discover scheduled and manual runs
that start after the section opens. Render the established running state as an
animated loader and preserve the row's accessible state, compact geometry, and
non-running health dots.
## In scope
- Add the component regression test first and capture its idle-to-running
failure.
- Gate the established live-summary refresh on the section's visible state.
- Render and test the automation-scoped running loader.
- Extend the existing desktop automation-sidebar Playwright spec with the live
transition.
## Out of scope
- Backend automation lifecycle events or API changes.
- Automation schedule or run-state changes.
- Mobile navigation or layout changes.
- Refactoring the agenda, detail view, or shared automation row derivation.
## Acceptance
- An idle automation already visible in the sidebar changes to the animated
running indicator within the next live-refresh round trip, without a reload.
- The final open run disappearing restores the row's idle or paused dot, and
folded/collapsed sidebar states issue no repeat summary requests.
- The animated indicator is decorative, has a stable test ID, and retains the
localized Running state for assistive technology without changing row
geometry.
## Verification
```bash
cd apps
pnpm --filter @kandev/web exec vitest run \
components/app-sidebar/sections/automations-section.test.tsx \
components/runs/use-live-refresh.test.ts
pnpm --filter @kandev/web run typecheck
pnpm --filter @kandev/web exec eslint \
components/app-sidebar/sections/automations-section.tsx \
components/app-sidebar/sections/automations-section.test.tsx
cd web
pnpm e2e:run --project chromium tests/automations-sidebar.spec.ts
```
## Files likely touched
- `apps/web/components/app-sidebar/sections/automations-section.tsx`
- `apps/web/components/app-sidebar/sections/automations-section.test.tsx`
- `apps/web/e2e/tests/automations-sidebar.spec.ts`
## Dependencies
None.
## Risks
- The live-response E2E wait can match initial hydration if it is armed before
the section has completed its first authoritative summary read.
- Reusing `useLiveRefresh` must preserve its latest-callback ref behavior and
cleanup when visibility changes.
## Parallelism
`sequential`
## Inputs
- `AC-OFFICE-AUTOMATION-RUNS-001.9` and the Automation Runs navigation design.
- Existing `useAutomationSummaries`, `useLiveRefresh`, and automation-sidebar
test patterns.
- Existing task-switcher animated status treatment as the visual/accessibility
precedent.
## Results
- Added the visible-section `useLiveRefresh` gate and reused the guarded summary refresh.
- Rendered an automation-scoped animated loader for running rows while preserving localized state text and non-running dots.
- Added component coverage for idle-to-running-to-idle transitions and desktop Chromium coverage for a seeded open run without navigation.
- Passed focused Vitest (22 tests), typecheck, targeted ESLint, Chromium E2E (4 tests), and specification lint.
- Fixup preserved the last successful summary after refresh errors and gated polling at the shared 768px mobile boundary; the added regressions passed with the focused suite at 30 tests, typecheck, targeted ESLint, and Chromium E2E (4 tests).
@@ -27,6 +27,7 @@ An automation that produces a report — a nightly drift sweep, a dependency aud
- **AC-OFFICE-AUTOMATION-RUNS-001.6:** Reasons an enabled automation will not fire, each shown in place of a time: a run is still open and `max_concurrent_runs` is reached; no schedule is set; the trigger itself is switched off. An automation that will not fire stays in the agenda holding its reason — dropping it would make the agenda look complete when it is not.
- **AC-OFFICE-AUTOMATION-RUNS-001.7:** A standing constraint sits under the agenda, which is exactly what it qualifies: scheduled automations only fire while kandev is running.
- **AC-OFFICE-AUTOMATION-RUNS-001.8:** **Recent runs** — the cross-automation feed inline, newest first, each entry naming its automation. "Did anything go wrong overnight" is the other half of why someone opens this page; behind a link, they would have to ask for it twice. The filtered lens stays one click away for digging.
- **AC-OFFICE-AUTOMATION-RUNS-001.9:** While the Automations section is visible, a scheduled or manually triggered run shall update its sidebar row without a page reload. An open run replaces the health dot with an animated running indicator and exposes the localized Running state to assistive technology; after the last open run finishes, the row returns to its non-running health indicator. The section shall stop refreshing its health summaries when it is folded, the desktop rail is collapsed, or the rail is not rendered.
## System design
@@ -48,6 +48,17 @@ The automation is the object; its runs are its history. The surface is a list an
nav lists automations.
- The section fetches only while it is on screen — a collapsed rail or a folded
section issues no requests, since the sidebar is mounted on every page.
- While the section is expanded in the desktop rail, `AutomationsSection` uses
the shared 10-second live-refresh cadence to re-read
`automation.summaries`. `useAutomationSummaries` retains its request-order
guard, so a slower earlier response cannot overwrite a later refresh. Folding
the section, collapsing the rail, or unmounting the desktop-only rail disables
the cadence.
- `buildAutomationRows` remains the single health-state derivation. An idle or
paused row keeps its existing health dot; a running row renders an animated
loader in the same compact slot. The loader is decorative and the existing
localized state label remains the screen-reader contract, so animation does
not replace the semantic Running state.
**`/automations` — the agenda, not an index**
@@ -248,6 +259,8 @@ The action is workspace-scoped: the caller must be authorized for `workspace_id`
| Workspace changes while the list is open | Rows from the previous workspace are never shown under the new one, not even for one frame |
| Workspace changes while a detail page is open | The page leaves for `/automations`. An automation belongs to one workspace, so continuing to show it under a sidebar that says otherwise is a lie about where the user is |
| A run finishes while the page is open | The page stops calling it running without a reload. Polling runs only while something is open, so an idle workspace issues no repeat requests |
| A run starts after the Automations sidebar section is opened | The visible section re-reads health summaries on the live-refresh cadence and replaces the row's health dot with the running indicator without a reload |
| The Automations sidebar section is folded or the desktop rail is collapsed | Health-summary refresh stops; reopening the visible section performs an authoritative read before displaying current health |
| A visible run is still reported as open while the health summary says there are no open runs | The detail rail/drawer continues reading until the visible run receives a terminal status, then moves it to Completed without a reload |
| An open run falls outside the page's own run window | The open count comes from the server, not from the loaded window, so the page still reports work in flight and keeps polling |
| A user stops a running reused turn | The action addresses its stored run/session/turn identity, cancels that exact turn, and marks only that run failed. |
@@ -289,6 +302,8 @@ The action is workspace-scoped: the caller must be authorized for `workspace_id`
- **GIVEN** an automation run finished successfully, **WHEN** the user opens it and replies, **THEN** the agent continues in the same session and worktree rather than reporting that the session has ended.
- **GIVEN** an automation whose newest run is older than the workspace feed's cap reaches back, **WHEN** its row renders, **THEN** it still shows that run's outcome rather than "No runs yet".
- **GIVEN** a run is in flight, **WHEN** the user leaves the page open, **THEN** it stops reading "Running" once the run finishes, without a reload.
- **GIVEN** an idle automation is visible in the desktop sidebar, **WHEN** its schedule starts a run, **THEN** the same row shows the animated running indicator and localized Running state without a reload.
- **GIVEN** a running automation is visible in the desktop sidebar, **WHEN** its last open run finishes, **THEN** the same row returns to its non-running health indicator without a reload.
- **GIVEN** the detail rail or drawer shows a run as Running while the health summary reports zero open runs, **WHEN** that run receives a terminal status, **THEN** the run moves to Completed without the user reloading the page.
- **GIVEN** an automation with an open run older than its own capped run window, **WHEN** its detail page renders, **THEN** it still reports that something is running and keeps polling, because the count comes from the server rather than from the window.
- **GIVEN** the user switches to another workspace while a detail page is open, **WHEN** the switch lands, **THEN** the page leaves for `/automations` rather than showing another workspace's automation.