Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -512,7 +513,10 @@ export function EngineGoalsClient({
<FleetReadinessBanner readiness={fleetReadiness} />
</div>
)}
{/* 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. */}
<NowWorkingTicker projectId={projectId} userId={currentUserId} />
{/* Single-row toolbar: [View switcher] [Filters▾] [🔍] [Sort▾] [Discuss] [+ Add Goal] */}
<div className="mb-6 flex items-center gap-2">
<GoalsViewSwitcher view={view} onChange={setView} />

Expand Down
124 changes: 124 additions & 0 deletions src/components/engine/now-working-ticker.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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(<NowWorkingTicker projectId={PROJECT_ID} userId={USER_ID} />);

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(<NowWorkingTicker projectId={PROJECT_ID} userId={USER_ID} />);

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(<NowWorkingTicker projectId={PROJECT_ID} userId={USER_ID} />);

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(<NowWorkingTicker projectId={PROJECT_ID} userId={USER_ID} />);

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(<NowWorkingTicker projectId={PROJECT_ID} userId={USER_ID} />);
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(<NowWorkingTicker projectId={PROJECT_ID} userId={USER_ID} />);
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(),
);
});
});
100 changes: 100 additions & 0 deletions src/components/engine/now-working-ticker.tsx
Original file line number Diff line number Diff line change
@@ -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<RunningItem[]>([]);

// 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 (
<div
data-testid="now-working-ticker"
className="flex h-9 items-center gap-2 overflow-hidden border-b border-gray-100 bg-[#F5F5F5] px-4 text-xs"
aria-label="Goals currently running"
>
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span className="inline-flex h-2 w-2 rounded-full bg-[#4CD964]" />
<span className="absolute inline-flex h-2 w-2 animate-ping rounded-full bg-[#4CD964] opacity-60" />
</span>
<span className="shrink-0 font-medium text-[#8E8E93]">Now working</span>
<span className="flex min-w-0 items-center gap-1.5 truncate text-[#1C1C1E]">
<span className="truncate font-medium">{first.title}</span>
<span className="shrink-0 text-[#8E8E93]">→</span>
<span className="truncate text-[#8E8E93]">{first.currentTask?.title}</span>
</span>
{rest.length > 0 && (
<span className="ml-auto shrink-0 rounded-full bg-[#E8F8EB] px-2 py-0.5 text-[10px] font-semibold text-[#2F855A]">
+{rest.length} more
</span>
)}
</div>
);
}
Loading