fix: restore automation sidebar running indicator (#3064)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user