diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.test.tsx b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.test.tsx index 6e231517d..e13949074 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.test.tsx +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.test.tsx @@ -21,6 +21,11 @@ vi.mock("@/components/layout/page-bar-context", async (importOriginal) => { vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); vi.mock("@/components/engine/agent-roster-rail", () => ({ AgentRosterRail: () => null })); vi.mock("@/lib/realtime/use-channel", () => ({ useChannel: vi.fn() })); +// Stub the live "Now working" ticker: it fires its own fleet-queue fetch + setState on +// every mount, which would add async churn (and stray fleet-queue calls) to each of the +// dozens of EngineGoalsClient renders here. It has full coverage in its own test file +// (now-working-ticker.test.tsx), so render it inert to keep this suite fast and isolated. +vi.mock("@/components/engine/now-working-ticker", () => ({ NowWorkingTicker: () => null })); const mockFetch = vi.fn(); global.fetch = mockFetch as unknown as typeof fetch; diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.tsx b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.tsx index 110d544d7..eec4c288d 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.tsx +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/engine-goals-client.tsx @@ -22,6 +22,7 @@ import type { FleetReadiness } from "@/lib/agent/project-fleet-readiness"; import { GoalCancelDialog } from "@/components/engine/goal-cancel-dialog"; import { GoalDeleteDialog } from "@/components/engine/goal-delete-dialog"; import { GoalEditDialog, type ReleaseOption } from "@/components/engine/goal-edit-dialog"; +import { NowWorkingTicker } from "@/components/engine/now-working-ticker"; import { usePageSection } from "@/hooks/use-page-section"; import { usePageBarRightSlot } from "@/components/layout/page-bar-context"; import { @@ -512,7 +513,10 @@ export function EngineGoalsClient({ )} -{/* Single-row toolbar: [View switcher] [Filters▾] [🔍] [Sort▾] [Discuss] [+ Add Goal] */} + {/* Live "Now working" strip — renders only while a machine is actively running a + goal in this project; null (no layout shift) otherwise. */} + + {/* Single-row toolbar: [View switcher] [Filters▾] [🔍] [Sort▾] [Discuss] [+ Add Goal] */}
diff --git a/src/components/engine/now-working-ticker.test.tsx b/src/components/engine/now-working-ticker.test.tsx new file mode 100644 index 000000000..aca2af483 --- /dev/null +++ b/src/components/engine/now-working-ticker.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import React from "react"; + +// Capture the channel handler so a test can simulate a live `engine.changed` event. +vi.mock("@/lib/realtime/use-channel", () => ({ useChannel: vi.fn() })); + +import { useChannel } from "@/lib/realtime/use-channel"; +import { NowWorkingTicker } from "./now-working-ticker"; + +const PROJECT_ID = "proj-1"; +const USER_ID = "user-1"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +/** A fleet-queue goal row as the API returns it under `data.goals`. */ +function goal(over: Record = {}) { + return { + goalId: "g1", + title: "Ship the cutover", + displayNumber: 7, + status: "running", + currentTask: { taskId: "t1", title: "Investigate the schema" }, + ...over, + }; +} + +function respondWith(goals: unknown[]) { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ data: { goals } }) }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("NowWorkingTicker", () => { + it("renders nothing when no goals are running", async () => { + // A queued goal (not running) and a running goal with no currentTask are both excluded. + respondWith([ + goal({ status: "queued", currentTask: null }), + goal({ goalId: "g2", status: "running", currentTask: null }), + ]); + + render(); + + await waitFor(() => expect(mockFetch).toHaveBeenCalled()); + expect(screen.queryByTestId("now-working-ticker")).not.toBeInTheDocument(); + }); + + it("renders nothing when the fleet-queue fetch fails", async () => { + mockFetch.mockResolvedValue({ ok: false, json: async () => ({}) }); + + render(); + + await waitFor(() => expect(mockFetch).toHaveBeenCalled()); + expect(screen.queryByTestId("now-working-ticker")).not.toBeInTheDocument(); + }); + + it("renders the strip with the goal title and current task title when a goal is running", async () => { + respondWith([goal()]); + + render(); + + const strip = await screen.findByTestId("now-working-ticker"); + expect(strip).toHaveTextContent("Ship the cutover"); + expect(strip).toHaveTextContent("Investigate the schema"); + }); + + it("scopes the fetch to the project and shows a +N more badge for extra running goals", async () => { + respondWith([ + goal(), + goal({ goalId: "g2", title: "Second goal", currentTask: { taskId: "t2", title: "Run the build" } }), + ]); + + render(); + + const strip = await screen.findByTestId("now-working-ticker"); + expect(strip).toHaveTextContent("+1 more"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`projectIds=${PROJECT_ID}`), + ); + }); + + it("refetches when an engine.changed event fires on the project channel", async () => { + // First load: nothing running → no strip. + respondWith([]); + render(); + await waitFor(() => expect(mockFetch).toHaveBeenCalled()); + expect(screen.queryByTestId("now-working-ticker")).not.toBeInTheDocument(); + + // The first useChannel call is the project channel; grab its handler. + const handler = vi.mocked(useChannel).mock.calls[0]![1]; + + // A goal starts running; the next refetch surfaces it. + respondWith([goal()]); + handler({ type: "engine.changed", ids: { projectId: PROJECT_ID }, ts: "" }); + + const strip = await screen.findByTestId("now-working-ticker"); + expect(strip).toHaveTextContent("Ship the cutover"); + }); + + it("removes the strip when a refetch shows no goals are running", async () => { + // First load: a goal is running → the strip is visible. + respondWith([goal()]); + render(); + await screen.findByTestId("now-working-ticker"); + + // The first useChannel call is the project channel; grab its handler. + const handler = vi.mocked(useChannel).mock.calls[0]![1]; + + // The run finishes; the next refetch returns nothing → the strip disappears. + respondWith([]); + handler({ type: "engine.changed", ids: { projectId: PROJECT_ID }, ts: "" }); + + await waitFor(() => + expect(screen.queryByTestId("now-working-ticker")).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/engine/now-working-ticker.tsx b/src/components/engine/now-working-ticker.tsx new file mode 100644 index 000000000..99e1e8727 --- /dev/null +++ b/src/components/engine/now-working-ticker.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Interactor, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useChannel } from "@/lib/realtime/use-channel"; + +// A thin "Now Working" activity strip pinned to the top of a project's Goals page. It +// surfaces, per project, the goals a machine is ACTIVELY executing right now (status +// `running` with a live `currentTask`) — the per-project complement to the account-wide +// fleet sidebar, which can be collapsed and spans every project. It draws from the SAME +// GOAL-grain fleet-queue read the sidebar uses (`/api/v1/me/engine/fleet-queue`), narrowed +// to this project, so what it shows can't drift from what the fleet is doing. When nothing +// is running it renders null (no strip, no layout shift) — a fetch failure or empty result +// is indistinguishable from "nothing running", which is the correct, quiet default. + +/** One running goal + the task its machine is on — the subset of a fleet-queue row this + * strip needs. Mirrors `FleetGoalRow` (fleet-queue.ts) loosely so we don't import the + * server type into a client bundle. */ +interface RunningItem { + goalId: string; + title: string; + displayNumber: number | null; + status: string; + currentTask: { taskId: string; title: string } | null; +} + +interface NowWorkingTickerProps { + /** The project's db id — scopes the fleet-queue read to this project. */ + projectId: string; + /** The viewer's user id — used for the machine-heartbeat notifications channel. */ + userId: string; +} + +export function NowWorkingTicker({ projectId, userId }: NowWorkingTickerProps) { + const [runningItems, setRunningItems] = useState([]); + + // Refetch the project-scoped fleet queue and keep only the goals a machine is actively + // running (status `running` AND a live `currentTask`). Best-effort: any failure (network, + // non-200, malformed body) clears to empty so the strip simply disappears — never a stale + // or error state, never a layout shift. + const refresh = useCallback(async () => { + try { + const res = await fetch( + `/api/v1/me/engine/fleet-queue?projectIds=${encodeURIComponent(projectId)}`, + ); + if (!res.ok) { + setRunningItems([]); + return; + } + const json = (await res.json()) as { data?: { goals?: RunningItem[] } }; + const goals = json.data?.goals ?? []; + setRunningItems(goals.filter((g) => g.status === "running" && g.currentTask)); + } catch { + setRunningItems([]); + } + }, [projectId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // Live updates: a coarse `engine.changed` on the project channel covers goal/task state + // transitions (a run starting or finishing); the account notifications channel covers + // machine heartbeat changes that flip a machine online/offline mid-run. Either prompts a + // refetch so the strip tracks what's actually executing. + useChannel(`project:${projectId}`, (event) => { + if (event.type === "engine.changed") void refresh(); + }); + useChannel(`notifications:${userId}`, () => void refresh()); + + // Nothing running ⇒ render nothing (no strip, no reserved height). + if (runningItems.length === 0) return null; + + const [first, ...rest] = runningItems; + + return ( +
+
+ ); +}