diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 78a985574c..bfc7732a02 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -111,7 +111,7 @@ Running provider-native subagents contribute `running` to the workspace owned by ## The subagents track -The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children: +One collapsible section of the composer track lane in an agent's pane (`packages/app/src/subagents/track.tsx`), stacked with any other track the agent has. It combines two kinds of children: - **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is: diff --git a/docs/glossary.md b/docs/glossary.md index bb917b8a5a..6a408f9643 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -30,7 +30,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). - **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent). -- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. +- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. The agent's **Heartbeats track** lists the ones still on a cadence for that agent on that host; opening a row goes to it on Schedules (`/schedules?serverId=…&scheduleId=…`, keyed by both because schedule ids are host-local). Paused, completed, and expired heartbeats stay on Schedules and leave the track. - **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`). - **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`). - **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent. @@ -38,7 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar". - **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar". - **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`). -- **Composer track** — A contextual lane above the composer input. Specific tracks use the ` track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`). +- **Composer track** — A contextual lane above the composer input. Specific tracks use the ` track` form: **Queue track**, **Heartbeats track**, **Subagents track**. Tracks above the input are sections of one card so several can stack without either one owning the seam with the composer. Code: `ComposerTracks` / `ComposerTrackSection` (`packages/app/src/composer/tracks.tsx`), queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `HeartbeatsTrack` (`packages/app/src/heartbeats/track.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`). - **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term. - **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar". - **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying). diff --git a/packages/app/e2e/browser/agent-heartbeat-track.spec.ts b/packages/app/e2e/browser/agent-heartbeat-track.spec.ts new file mode 100644 index 0000000000..a17e6d0a2d --- /dev/null +++ b/packages/app/e2e/browser/agent-heartbeat-track.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "../support/fixtures"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import { + captureHeartbeatEvidence, + deleteHeartbeat, + expectHeartbeatDetails, + expectHeartbeatVisible, + expectWorkspaceDone, + expectWorkspaceRunning, + openHeartbeat, + openHeartbeatAndSubagentTracks, + removeSeededSubagent, + returnToAgentHeartbeat, + seedAgentWithHeartbeat, +} from "../support/helpers/heartbeats"; + +test("an agent heartbeat stays visible and keeps its workspace running", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const scenario = await seedAgentWithHeartbeat(); + + try { + await openAgentRoute(page, { + workspaceId: scenario.workspace.workspaceId, + agentId: scenario.parent.id, + }); + await expectWorkspaceRunning(page); + await openHeartbeatAndSubagentTracks(page); + await expectHeartbeatVisible(page, scenario.heartbeat); + await captureHeartbeatEvidence(page, testInfo, "heartbeat-stacked-tracks"); + + await openHeartbeat(page, scenario.heartbeat); + await expectHeartbeatDetails(page, scenario.heartbeat); + await captureHeartbeatEvidence(page, testInfo, "heartbeat-schedule-details"); + + await removeSeededSubagent(scenario); + await returnToAgentHeartbeat(page); + await expectWorkspaceRunning(page); + await page.getByRole("button", { name: "1 heartbeat" }).click(); + await deleteHeartbeat(page, scenario.heartbeat); + await expect.poll(scenario.readHeartbeatExists, { timeout: 30_000 }).toBe(false); + await expect(scenario.readAgentStatuses()).resolves.toMatchObject({ + [scenario.parent.id]: "idle", + }); + await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done"); + await expectWorkspaceDone(page); + await captureHeartbeatEvidence(page, testInfo, "heartbeat-removed-workspace-done"); + } finally { + await scenario.cleanup().catch(() => undefined); + } +}); diff --git a/packages/app/e2e/support/helpers/heartbeats.ts b/packages/app/e2e/support/helpers/heartbeats.ts new file mode 100644 index 0000000000..d1791ccc89 --- /dev/null +++ b/packages/app/e2e/support/helpers/heartbeats.ts @@ -0,0 +1,172 @@ +import { expect, type Page, type TestInfo } from "@playwright/test"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { seedParentWithSubagent } from "./subagents"; +import { seedWorkspace, type SeededWorkspace } from "./seed-client"; + +interface HeartbeatScheduleClient { + scheduleCreate(input: { + prompt: string; + name: string; + cadence: { type: "every"; everyMs: number }; + target: { type: "agent"; agentId: string }; + runOnCreate: false; + }): Promise<{ schedule: ScheduleSummary | null; error: string | null }>; + scheduleDelete(input: { id: string }): Promise<{ error: string | null }>; + scheduleList(): Promise<{ schedules: ScheduleSummary[]; error: string | null }>; +} + +export interface AgentHeartbeatScenario { + workspace: SeededWorkspace; + parent: { id: string; title: string }; + child: { id: string; title: string }; + heartbeat: { id: string; name: string; cadence: string }; + readWorkspaceStatus(): Promise; + readHeartbeatExists(): Promise; + readAgentStatuses(): Promise>; + cleanup(): Promise; +} + +export async function seedAgentWithHeartbeat(): Promise { + const workspace = await seedWorkspace({ + repoPrefix: "agent-heartbeat-track-", + title: "Heartbeat workspace", + }); + const scheduleClient = workspace.client as unknown as HeartbeatScheduleClient; + try { + const agents = await seedParentWithSubagent(workspace, { + parentTitle: "Build babysitter", + childTitle: "Build observer", + }); + await workspace.client.waitForAgentUpsert( + agents.parent.id, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + await workspace.client.waitForAgentUpsert( + agents.child.id, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + const created = await scheduleClient.scheduleCreate({ + prompt: "Check the build and report any failures.", + name: "Watch the build", + cadence: { type: "every", everyMs: 15 * 60_000 }, + target: { type: "agent", agentId: agents.parent.id }, + runOnCreate: false, + }); + if (!created.schedule) { + throw new Error(created.error ?? "Failed to create heartbeat"); + } + const heartbeatId = created.schedule.id; + return { + workspace, + parent: agents.parent, + child: agents.child, + heartbeat: { id: heartbeatId, name: "Watch the build", cadence: "Every 15 minutes" }, + readWorkspaceStatus: async () => { + const result = await workspace.client.fetchWorkspaces(); + return ( + ( + result.entries.find((entry) => entry.id === workspace.workspaceId) as + | ((typeof result.entries)[number] & { status?: string }) + | undefined + )?.status ?? null + ); + }, + readHeartbeatExists: async () => { + const result = await scheduleClient.scheduleList(); + return result.schedules.some((schedule) => schedule.id === heartbeatId); + }, + readAgentStatuses: async () => { + const result = await workspace.client.fetchAgents({ scope: "active" }); + return Object.fromEntries(result.entries.map(({ agent }) => [agent.id, agent.status])); + }, + cleanup: async () => { + await scheduleClient.scheduleDelete({ id: heartbeatId }).catch(() => undefined); + await workspace.cleanup(); + }, + }; + } catch (error) { + await workspace.cleanup(); + throw error; + } +} + +export async function expectWorkspaceRunning(page: Page): Promise { + await expect( + page + .getByRole("button", { name: "Heartbeat workspace", exact: true }) + .getByTestId("workspace-status-indicator-running"), + ).toBeVisible({ timeout: 60_000 }); +} + +export async function expectWorkspaceDone(page: Page): Promise { + const workspaceRow = page.getByRole("button", { name: "Heartbeat workspace", exact: true }); + await expect(workspaceRow.getByTestId("workspace-status-indicator-done")).toBeVisible({ + timeout: 60_000, + }); + await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toHaveCount(0); +} + +export async function openHeartbeatAndSubagentTracks(page: Page): Promise { + await page.getByRole("button", { name: "1 heartbeat" }).click(); + await page.getByRole("button", { name: "1 subagent" }).click(); +} + +export async function expectHeartbeatVisible( + page: Page, + heartbeat: AgentHeartbeatScenario["heartbeat"], +): Promise { + await expect( + page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }), + ).toBeVisible({ timeout: 30_000 }); +} + +export async function openHeartbeat( + page: Page, + heartbeat: AgentHeartbeatScenario["heartbeat"], +): Promise { + await page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }).click(); +} + +export async function expectHeartbeatDetails( + page: Page, + heartbeat: AgentHeartbeatScenario["heartbeat"], +): Promise { + const sheet = page.getByTestId("schedule-form-sheet"); + await expect(sheet).toBeVisible({ timeout: 30_000 }); + await expect(sheet.getByText("Edit heartbeat", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`scheduleId=${heartbeat.id}`)); + await expect(page.getByText("*/15 * * * *", { exact: true }).first()).toBeVisible(); +} + +export async function returnToAgentHeartbeat(page: Page): Promise { + await page.goBack(); + await expect(page.getByRole("button", { name: "1 heartbeat" })).toBeVisible({ + timeout: 30_000, + }); +} + +export async function removeSeededSubagent(scenario: AgentHeartbeatScenario): Promise { + await scenario.workspace.client.archiveAgent(scenario.child.id); +} + +export async function deleteHeartbeat( + page: Page, + heartbeat: AgentHeartbeatScenario["heartbeat"], +): Promise { + const row = page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }); + page.once("dialog", (dialog) => void dialog.accept()); + await page.getByRole("button", { name: `Delete heartbeat ${heartbeat.name}` }).click(); + await expect(row).toHaveCount(0, { timeout: 30_000 }); +} + +export async function captureHeartbeatEvidence( + page: Page, + testInfo: TestInfo, + name: string, +): Promise { + const path = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path, fullPage: true }); + await testInfo.attach(name, { path, contentType: "image/png" }); +} diff --git a/packages/app/src/app/schedules.tsx b/packages/app/src/app/schedules.tsx index 5f13b7a141..c3486e1906 100644 --- a/packages/app/src/app/schedules.tsx +++ b/packages/app/src/app/schedules.tsx @@ -1,10 +1,18 @@ +import { useMemo } from "react"; +import { useLocalSearchParams } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { SchedulesScreen } from "@/screens/schedules-screen"; +import { parseScheduleFocus } from "@/utils/host-routes"; export default function SchedulesRoute() { + const params = useLocalSearchParams<{ serverId?: string; scheduleId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : undefined; + const scheduleId = typeof params.scheduleId === "string" ? params.scheduleId : undefined; + const focus = useMemo(() => parseScheduleFocus({ serverId, scheduleId }), [serverId, scheduleId]); + return ( - + ); } diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index c591b03d42..d8c93f316f 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles"; import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row"; import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; import type { AggregatedSchedule } from "@/hooks/use-schedules"; +import { resolveDeleteScheduleDialog } from "@/schedules/delete-schedule"; import type { ScheduleDerivedState } from "@/schedules/schedule-derivation"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -113,13 +114,12 @@ function SchedulesTableRow({ const handleDelete = useCallback(() => { void (async () => { - const productName = scheduleProductName(schedule); - const confirmed = await confirmDialog({ - title: `Delete ${productName.toLowerCase()}`, - message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`, - confirmLabel: "Delete", - destructive: true, - }); + const confirmed = await confirmDialog( + resolveDeleteScheduleDialog({ + productName: scheduleProductName(schedule), + title: resolveScheduleTitle(schedule), + }), + ); if (!confirmed) { return; } diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx new file mode 100644 index 0000000000..587338270b --- /dev/null +++ b/packages/app/src/composer/tracks.tsx @@ -0,0 +1,367 @@ +import { + Children, + isValidElement, + useCallback, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { + Pressable, + ScrollView, + Text, + View, + type GestureResponderEvent, + type PressableStateCallbackType, +} from "react-native"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import type { Theme } from "@/styles/theme"; + +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedChevronRight = withUnistyles(ChevronRight); + +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const TRACK_LIST_MAX_HEIGHT = 200; + +/** + * The lane above the composer input. Every contextual track an agent can have — + * heartbeats, subagents — is a section of one card that tucks under the + * composer's top edge, so tracks stack without each one claiming the seam. + * + * Sections are supplied as children and any of them may be absent; the caller + * renders `null` for a track with nothing to show and the card disappears when + * every track is empty. + */ +export function ComposerTracks({ children }: { children: ReactNode }): ReactElement | null { + const sections = Children.toArray(children); + if (sections.length === 0) { + return null; + } + + return ( + + + + {sections.map((section, index) => ( + + {section} + + ))} + + + + ); +} + +export interface ComposerTrackSectionProps { + /** Names the track and its disclosure control: "2 subagents", "1 heartbeat". */ + label: string; + testID: string; + headerTestID: string; + /** Optional control on the header row, e.g. a bulk action for the whole track. */ + renderHeaderAction?: () => ReactNode; + children: ReactNode; +} + +/** + * One track: a disclosure header that names the track, and its rows. Collapsed + * by default so a busy agent does not push the composer down the screen. + */ +export function ComposerTrackSection({ + label, + testID, + headerTestID, + renderHeaderAction, + children, +}: ComposerTrackSectionProps): ReactElement { + const [expanded, setExpanded] = useState(false); + const toggleExpanded = useCallback(() => { + setExpanded((current) => !current); + }, []); + + const headerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType) => [ + styles.headerToggle, + (hovered || pressed) && styles.headerActive, + ], + [], + ); + + return ( + + + + {expanded ? ( + + ) : ( + + )} + + {label} + + + {renderHeaderAction ? ( + {renderHeaderAction()} + ) : null} + + {expanded ? ( + + {children} + + ) : null} + + ); +} + +const EXPANDED_STATE = { expanded: true } as const; +const COLLAPSED_STATE = { expanded: false } as const; + +function expandedAccessibilityState(expanded: boolean) { + return expanded ? EXPANDED_STATE : COLLAPSED_STATE; +} + +export interface ComposerTrackRowProps { + /** `link` for a row that leaves the agent, `button` for one that acts in place. */ + accessibilityRole: "button" | "link"; + accessibilityLabel: string; + testID: string; + onPress: () => void; + renderLeading?: () => ReactNode; + label: string; + /** Muted trailing context — a provider note, a cadence. */ + secondary?: string; + actions?: () => ReactNode; +} + +/** + * One row in a track. The row control and its actions are siblings so an + * action never activates the row's navigation. + */ +export function ComposerTrackRow({ + accessibilityRole, + accessibilityLabel, + testID, + onPress, + renderLeading, + label, + secondary, + actions, +}: ComposerTrackRowProps): ReactElement { + const [hovered, setHovered] = useState(false); + const [pressed, setPressed] = useState(false); + const handleHoverIn = useCallback(() => setHovered(true), []); + const handleHoverOut = useCallback(() => setHovered(false), []); + const handlePressIn = useCallback(() => setPressed(true), []); + const handlePressOut = useCallback(() => setPressed(false), []); + return ( + + {() => ( + + + {renderLeading?.()} + + {label} + + {secondary ? ( + + {secondary} + + ) : null} + + {actions ? {actions()} : null} + + )} + + ); +} + +export interface ComposerTrackActionButtonProps { + accessibilityLabel: string; + testID: string; + tooltipLabel: string; + /** Callers own their themed icon so it re-renders alone on theme change. */ + renderIcon: (isActive: boolean) => ReactElement; + onPress: () => void; +} + +export function ComposerTrackActionButton({ + accessibilityLabel, + testID, + tooltipLabel, + renderIcon, + onPress, +}: ComposerTrackActionButtonProps): ReactElement { + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onPress(); + }, + [onPress], + ); + + return ( + + + + {({ hovered, pressed }) => renderIcon(hovered || pressed)} + + + + {tooltipLabel} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + outer: { + width: "100%", + alignItems: "center", + paddingHorizontal: theme.spacing[4], + }, + track: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + // The composer overlaps the card's bottom edge, which is what makes the + // tracks read as one surface with it. + marginBottom: -theme.spacing[4], + }, + surface: { + alignSelf: "stretch", + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderBottomWidth: 0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + overflow: "hidden", + paddingBottom: theme.spacing[4], + }, + sectionDivided: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + }, + header: { + flexDirection: "row", + alignItems: "center", + }, + headerExpanded: { + flexDirection: "row", + alignItems: "center", + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + headerToggle: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[1], + paddingVertical: theme.spacing[2], + }, + headerAction: { + paddingRight: theme.spacing[2], + }, + headerActive: { + backgroundColor: theme.colors.surface2, + }, + headerLabel: { + flexShrink: 1, + minWidth: 0, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + scroll: { + maxHeight: TRACK_LIST_MAX_HEIGHT, + }, + scrollContent: { + paddingVertical: theme.spacing[1], + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + rowActive: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface2, + }, + rowMain: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + rowLabel: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + // Keep secondary context bounded so the row's own name stays readable on + // compact screens. + rowSecondary: { + flexShrink: 1, + minWidth: 0, + maxWidth: "45%", + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + actionCluster: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + actionButton: { + padding: theme.spacing[1], + alignItems: "center", + justifyContent: "center", + }, + tooltipText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/heartbeats/delete-heartbeat.test.ts b/packages/app/src/heartbeats/delete-heartbeat.test.ts new file mode 100644 index 0000000000..816bad00b0 --- /dev/null +++ b/packages/app/src/heartbeats/delete-heartbeat.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { resolveDeleteScheduleDialog } from "@/schedules/delete-schedule"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; +import { requestDeleteHeartbeat, type DeleteHeartbeatDeps } from "./delete-heartbeat"; + +interface RecordedDelete { + serverId: string; + scheduleId: string; +} + +interface FakeDeleteHeartbeatEnv { + deps: DeleteHeartbeatDeps; + recordedDeletes: RecordedDelete[]; + recordedConfirmInputs: ConfirmDialogInput[]; + recordedErrors: unknown[]; +} + +function createFakeEnv(options: { confirmResult?: boolean } = {}): FakeDeleteHeartbeatEnv { + const recordedDeletes: RecordedDelete[] = []; + const recordedConfirmInputs: ConfirmDialogInput[] = []; + const recordedErrors: unknown[] = []; + + return { + recordedDeletes, + recordedConfirmInputs, + recordedErrors, + deps: { + confirm: async (dialog) => { + recordedConfirmInputs.push(dialog); + return options.confirmResult ?? false; + }, + deleteHeartbeat: async (input) => { + recordedDeletes.push(input); + }, + reportError: (error) => { + recordedErrors.push(error); + }, + }, + }; +} + +const ROW = { serverId: "host-1", scheduleId: "schedule-1", title: "Watch the build" }; + +describe("requestDeleteHeartbeat", () => { + it("asks for destructive confirmation naming the heartbeat", async () => { + const env = createFakeEnv({ confirmResult: false }); + + await requestDeleteHeartbeat(ROW, env.deps); + + expect(env.recordedConfirmInputs).toEqual([ + resolveDeleteScheduleDialog({ productName: "Heartbeat", title: "Watch the build" }), + ]); + expect(env.recordedConfirmInputs[0]?.destructive).toBe(true); + }); + + it("deletes the heartbeat on its own host when the user confirms", async () => { + const env = createFakeEnv({ confirmResult: true }); + + await requestDeleteHeartbeat(ROW, env.deps); + + expect(env.recordedDeletes).toEqual([{ serverId: "host-1", scheduleId: "schedule-1" }]); + }); + + it("keeps the heartbeat when the user cancels", async () => { + const env = createFakeEnv({ confirmResult: false }); + + await requestDeleteHeartbeat(ROW, env.deps); + + expect(env.recordedDeletes).toEqual([]); + }); + + it("reports a rejected deletion instead of throwing", async () => { + const env = createFakeEnv({ confirmResult: true }); + const error = new Error("daemon offline"); + env.deps.deleteHeartbeat = async () => { + throw error; + }; + + await expect(requestDeleteHeartbeat(ROW, env.deps)).resolves.toBeUndefined(); + expect(env.recordedErrors).toEqual([error]); + }); +}); diff --git a/packages/app/src/heartbeats/delete-heartbeat.ts b/packages/app/src/heartbeats/delete-heartbeat.ts new file mode 100644 index 0000000000..dc0ab6e2db --- /dev/null +++ b/packages/app/src/heartbeats/delete-heartbeat.ts @@ -0,0 +1,36 @@ +import { resolveDeleteScheduleDialog } from "@/schedules/delete-schedule"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; + +export interface DeleteHeartbeatDeps { + confirm: (input: ConfirmDialogInput) => Promise; + deleteHeartbeat: (input: { serverId: string; scheduleId: string }) => Promise; + reportError: (error: unknown) => void; +} + +export interface RequestDeleteHeartbeatInput { + serverId: string; + scheduleId: string; + title: string; +} + +/** + * Confirm, then delete through the shared schedule mutation, which removes the + * row optimistically and restores it when the daemon rejects the write. A + * rejected delete is reported to the user rather than thrown at the caller. + */ +export async function requestDeleteHeartbeat( + input: RequestDeleteHeartbeatInput, + deps: DeleteHeartbeatDeps, +): Promise { + const confirmed = await deps.confirm( + resolveDeleteScheduleDialog({ productName: "Heartbeat", title: input.title }), + ); + if (!confirmed) { + return; + } + try { + await deps.deleteHeartbeat({ serverId: input.serverId, scheduleId: input.scheduleId }); + } catch (error) { + deps.reportError(error); + } +} diff --git a/packages/app/src/heartbeats/navigate.ts b/packages/app/src/heartbeats/navigate.ts new file mode 100644 index 0000000000..732e1a1b6e --- /dev/null +++ b/packages/app/src/heartbeats/navigate.ts @@ -0,0 +1,11 @@ +import { router, type Href } from "expo-router"; +import { buildSchedulesRoute } from "@/utils/host-routes"; + +/** + * Open one heartbeat on the Schedules screen. The route names the host as well + * as the schedule, so a schedule id from one daemon cannot resolve against + * another's. + */ +export function navigateToHeartbeat(target: { serverId: string; scheduleId: string }): void { + router.push(buildSchedulesRoute(target) as Href); +} diff --git a/packages/app/src/heartbeats/select.test.ts b/packages/app/src/heartbeats/select.test.ts new file mode 100644 index 0000000000..a97205d8c1 --- /dev/null +++ b/packages/app/src/heartbeats/select.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import type { AggregatedSchedule } from "@/schedules/aggregated-schedules"; +import { selectAgentHeartbeats } from "./select"; + +const NOW = Date.parse("2026-07-02T00:00:00.000Z"); +const AGENT_ID = "00000000-0000-4000-8000-000000000001"; +const OTHER_AGENT_ID = "00000000-0000-4000-8000-000000000002"; + +function makeHeartbeat(overrides: Partial = {}): AggregatedSchedule { + return { + id: "schedule-1", + name: "Watch the build", + prompt: "Check on the build", + cadence: { type: "every", everyMs: 900_000 }, + target: { type: "agent", agentId: AGENT_ID }, + status: "active", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + nextRunAt: "2026-07-02T01:00:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + serverId: "host-1", + serverName: "Laptop", + ...overrides, + }; +} + +function select(schedules: AggregatedSchedule[], agentId = AGENT_ID, serverId = "host-1") { + return selectAgentHeartbeats({ + schedules, + target: { serverId, agentId }, + now: NOW, + }); +} + +describe("selectAgentHeartbeats", () => { + it("shows the heartbeat title and human cadence for the current agent", () => { + expect(select([makeHeartbeat()])).toEqual([ + { + key: "host-1:schedule-1", + serverId: "host-1", + scheduleId: "schedule-1", + title: "Watch the build", + cadence: "Every 15 minutes", + }, + ]); + }); + + it("falls back to the first prompt line when the heartbeat has no name", () => { + const rows = select([ + makeHeartbeat({ name: null, prompt: " \nCheck the deploy\nthen report" }), + ]); + + expect(rows.map((row) => row.title)).toEqual(["Check the deploy"]); + }); + + it("describes cron cadences in words", () => { + const rows = select([ + makeHeartbeat({ cadence: { type: "cron", expression: "0 9 * * 1-5", timezone: "UTC" } }), + ]); + + expect(rows.map((row) => row.cadence)).toEqual(["Weekdays at 09:00 UTC"]); + }); + + it("ignores schedules targeting another agent or another host", () => { + const rows = select([ + makeHeartbeat({ id: "mine" }), + makeHeartbeat({ id: "other-agent", target: { type: "agent", agentId: OTHER_AGENT_ID } }), + makeHeartbeat({ id: "other-host", serverId: "host-2" }), + ]); + + expect(rows.map((row) => row.scheduleId)).toEqual(["mine"]); + }); + + it("ignores schedules that create new agents", () => { + const rows = select([ + makeHeartbeat({ + id: "new-agent", + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } }, + }), + ]); + + expect(rows).toEqual([]); + }); + + it("keeps only heartbeats that are still running on a cadence", () => { + const rows = select([ + makeHeartbeat({ id: "active" }), + makeHeartbeat({ id: "paused", status: "paused" }), + makeHeartbeat({ id: "completed", status: "completed" }), + makeHeartbeat({ id: "expired", expiresAt: "2026-07-01T00:00:00.000Z" }), + ]); + + expect(rows.map((row) => row.scheduleId)).toEqual(["active"]); + }); + + it("keys rows by host and schedule so ids cannot collide across hosts", () => { + const rows = selectAgentHeartbeats({ + schedules: [makeHeartbeat({ serverId: "host-2", serverName: "Desktop" })], + target: { serverId: "host-2", agentId: AGENT_ID }, + now: NOW, + }); + + expect(rows.map((row) => row.key)).toEqual(["host-2:schedule-1"]); + }); + + it("orders heartbeats oldest first", () => { + const rows = select([ + makeHeartbeat({ id: "newer", createdAt: "2026-07-01T10:00:00.000Z" }), + makeHeartbeat({ id: "older", createdAt: "2026-07-01T09:00:00.000Z" }), + ]); + + expect(rows.map((row) => row.scheduleId)).toEqual(["older", "newer"]); + }); + + it("returns the same empty list when nothing matches", () => { + expect(select([])).toBe(select([makeHeartbeat({ status: "paused" })])); + }); +}); diff --git a/packages/app/src/heartbeats/select.ts b/packages/app/src/heartbeats/select.ts new file mode 100644 index 0000000000..8cb9c8f595 --- /dev/null +++ b/packages/app/src/heartbeats/select.ts @@ -0,0 +1,58 @@ +import type { AggregatedSchedule } from "@/schedules/aggregated-schedules"; +import { deriveScheduleLifecycleState } from "@/schedules/schedule-derivation"; +import { formatCadence, resolveScheduleTitle } from "@/utils/schedule-format"; + +/** The agent whose conversation the track is rendered above. */ +export interface AgentHeartbeatTarget { + serverId: string; + agentId: string; +} + +/** One heartbeat, reduced to what the track renders and routes with. */ +export interface AgentHeartbeatRow { + /** `${serverId}:${scheduleId}` — schedule ids are host-local, so identity is the pair. */ + key: string; + serverId: string; + scheduleId: string; + title: string; + cadence: string; +} + +export interface SelectAgentHeartbeatsInput { + schedules: readonly AggregatedSchedule[]; + target: AgentHeartbeatTarget; + now: number; +} + +const EMPTY_HEARTBEAT_ROWS: AgentHeartbeatRow[] = []; + +export function agentHeartbeatKey(serverId: string, scheduleId: string): string { + return `${serverId}:${scheduleId}`; +} + +/** + * The heartbeats still babysitting this agent. Paused, completed, and expired + * heartbeats stay on the Schedules screen — they no longer represent ongoing + * work, so they leave the track. + */ +export function selectAgentHeartbeats(input: SelectAgentHeartbeatsInput): AgentHeartbeatRow[] { + const { schedules, target, now } = input; + const rows = schedules + .filter( + (schedule) => + schedule.serverId === target.serverId && + schedule.target.type === "agent" && + schedule.target.agentId === target.agentId && + deriveScheduleLifecycleState(schedule, now) === "active", + ) + .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt)) + .map((schedule) => ({ + key: agentHeartbeatKey(schedule.serverId, schedule.id), + serverId: schedule.serverId, + scheduleId: schedule.id, + title: resolveScheduleTitle(schedule), + cadence: formatCadence(schedule.cadence), + })); + + return rows.length > 0 ? rows : EMPTY_HEARTBEAT_ROWS; +} diff --git a/packages/app/src/heartbeats/track.tsx b/packages/app/src/heartbeats/track.tsx new file mode 100644 index 0000000000..a69fe19c97 --- /dev/null +++ b/packages/app/src/heartbeats/track.tsx @@ -0,0 +1,117 @@ +import { useCallback, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { HeartPulse, Trash2 } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { + ComposerTrackActionButton, + ComposerTrackRow, + ComposerTrackSection, +} from "@/composer/tracks"; +import type { Theme } from "@/styles/theme"; +import type { AgentHeartbeatRow } from "./select"; + +const ThemedHeartPulse = withUnistyles(HeartPulse); +const ThemedTrash2 = withUnistyles(Trash2); + +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); + +const ROW_ICON_SIZE = 14; +const ACTION_ICON_SIZE = 14; + +// The delete glyph only turns destructive once the pointer is on it: red is a +// color the user has aimed at, never a color the row wears at rest. +function renderDeleteIcon(isActive: boolean): ReactElement { + return ( + + ); +} + +function renderHeartbeatIcon(): ReactElement { + return ; +} + +export interface HeartbeatsTrackProps { + /** Non-empty. The composer decides which tracks exist; a track always has rows. */ + rows: AgentHeartbeatRow[]; + onOpenHeartbeat: (row: AgentHeartbeatRow) => void; + onDeleteHeartbeat: (row: AgentHeartbeatRow) => void; +} + +/** + * The heartbeats babysitting this agent. Each row names the heartbeat and how + * often it fires; opening one goes to it on Schedules, deleting one stops it. + */ +export function HeartbeatsTrack({ + rows, + onOpenHeartbeat, + onDeleteHeartbeat, +}: HeartbeatsTrackProps): ReactElement { + const { t } = useTranslation(); + const label = + rows.length === 1 + ? t("heartbeats.trackLabel", { count: rows.length }) + : t("heartbeats.trackLabelPlural", { count: rows.length }); + + return ( + + {rows.map((row) => ( + + ))} + + ); +} + +function HeartbeatsTrackRow({ + row, + onOpenHeartbeat, + onDeleteHeartbeat, +}: { + row: AgentHeartbeatRow; + onOpenHeartbeat: (row: AgentHeartbeatRow) => void; + onDeleteHeartbeat: (row: AgentHeartbeatRow) => void; +}): ReactElement { + const { t } = useTranslation(); + const handlePress = useCallback(() => onOpenHeartbeat(row), [onOpenHeartbeat, row]); + const handleDeletePress = useCallback(() => onDeleteHeartbeat(row), [onDeleteHeartbeat, row]); + + const renderActions = useCallback( + () => ( + + ), + [handleDeletePress, row.title, row.key, t], + ); + + return ( + + ); +} diff --git a/packages/app/src/heartbeats/use-agent-heartbeats.ts b/packages/app/src/heartbeats/use-agent-heartbeats.ts new file mode 100644 index 0000000000..88d709de01 --- /dev/null +++ b/packages/app/src/heartbeats/use-agent-heartbeats.ts @@ -0,0 +1,25 @@ +import { useMemo } from "react"; +import { useSchedules } from "@/hooks/use-schedules"; +import type { AggregatedSchedule } from "@/schedules/aggregated-schedules"; +import { selectAgentHeartbeats, type AgentHeartbeatRow } from "./select"; + +const EMPTY_SCHEDULES: AggregatedSchedule[] = []; + +/** + * The active heartbeats aimed at one agent, over the schedule list the + * Schedules screen already loads. Direct deletes update that cache + * optimistically; heartbeats created elsewhere appear on its next refresh. + */ +export function useAgentHeartbeats(target: { + serverId: string; + agentId: string; +}): AgentHeartbeatRow[] { + const { serverId, agentId } = target; + const { loadState } = useSchedules(); + const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES; + + return useMemo( + () => selectAgentHeartbeats({ schedules, target: { serverId, agentId }, now: Date.now() }), + [schedules, serverId, agentId], + ); +} diff --git a/packages/app/src/heartbeats/use-delete-heartbeat.ts b/packages/app/src/heartbeats/use-delete-heartbeat.ts new file mode 100644 index 0000000000..9518f15413 --- /dev/null +++ b/packages/app/src/heartbeats/use-delete-heartbeat.ts @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useToast } from "@/contexts/toast-context"; +import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { toErrorMessage } from "@/utils/error-messages"; +import { requestDeleteHeartbeat } from "./delete-heartbeat"; +import type { AgentHeartbeatRow } from "./select"; + +export function useDeleteHeartbeat({ + serverId, +}: { + serverId: string; +}): (row: AgentHeartbeatRow) => void { + const { deleteSchedule } = useScheduleMutations({ serverId }); + const toast = useToast(); + + return useCallback( + (row: AgentHeartbeatRow) => { + void requestDeleteHeartbeat( + { serverId: row.serverId, scheduleId: row.scheduleId, title: row.title }, + { + confirm: confirmDialog, + deleteHeartbeat: ({ scheduleId }) => deleteSchedule(scheduleId), + reportError: (error) => { + toast.error(toErrorMessage(error)); + }, + }, + ); + }, + [deleteSchedule, toast], + ); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 3e40dd2ab8..b6b2a5078d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1495,6 +1495,13 @@ export const ar: TranslationResources = { archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", archiveFinishedTooltip: "أرشفة المكتملين", }, + heartbeats: { + trackLabel: "{{count}} نبضة", + trackLabelPlural: "{{count}} نبضات", + openAction: "{{title}}، {{cadence}}", + deleteAction: "حذف النبضة {{title}}", + deleteTooltip: "حذف النبضة", + }, panels: { draft: { newAgent: "وكيل جديد", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ea61556947..fa551a1aa7 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1506,6 +1506,13 @@ export const en = { archiveFinishedAction: "Archive finished subagents", archiveFinishedTooltip: "Archive finished", }, + heartbeats: { + trackLabel: "{{count}} heartbeat", + trackLabelPlural: "{{count}} heartbeats", + openAction: "{{title}}, {{cadence}}", + deleteAction: "Delete heartbeat {{title}}", + deleteTooltip: "Delete heartbeat", + }, panels: { draft: { newAgent: "New Agent", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 22ab36e418..229e00e347 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1538,6 +1538,13 @@ export const es: TranslationResources = { archiveFinishedAction: "Archivar subagentes finalizados", archiveFinishedTooltip: "Archivar finalizados", }, + heartbeats: { + trackLabel: "{{count}} latido", + trackLabelPlural: "{{count}} latidos", + openAction: "{{title}}, {{cadence}}", + deleteAction: "Eliminar el latido {{title}}", + deleteTooltip: "Eliminar latido", + }, panels: { draft: { newAgent: "Nuevo agente", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 961ab00d56..25d5199e9f 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1542,6 +1542,13 @@ export const fr: TranslationResources = { archiveFinishedAction: "Archiver les sous-agents terminés", archiveFinishedTooltip: "Archiver les terminés", }, + heartbeats: { + trackLabel: "{{count}} pulsation", + trackLabelPlural: "{{count}} pulsations", + openAction: "{{title}}, {{cadence}}", + deleteAction: "Supprimer la pulsation {{title}}", + deleteTooltip: "Supprimer la pulsation", + }, panels: { draft: { newAgent: "Nouvel agent", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 44d4ac565d..ad1a76eecc 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1511,6 +1511,13 @@ export const ja: TranslationResources = { archiveFinishedAction: "完了したサブエージェントをアーカイブ", archiveFinishedTooltip: "完了した項目をアーカイブ", }, + heartbeats: { + trackLabel: "ハートビート{{count}}件", + trackLabelPlural: "ハートビート{{count}}件", + openAction: "{{title}}、{{cadence}}", + deleteAction: "ハートビート{{title}}を削除", + deleteTooltip: "ハートビートを削除", + }, panels: { draft: { newAgent: "新しいエージェント", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index df69e2889b..80eabc630e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1524,6 +1524,13 @@ export const ptBR: TranslationResources = { archiveFinishedAction: "Arquivar subagentes concluídos", archiveFinishedTooltip: "Arquivar concluídos", }, + heartbeats: { + trackLabel: "{{count}} batimento", + trackLabelPlural: "{{count}} batimentos", + openAction: "{{title}}, {{cadence}}", + deleteAction: "Excluir o batimento {{title}}", + deleteTooltip: "Excluir batimento", + }, panels: { draft: { newAgent: "Novo Agente", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ede4f74b90..193ac62f75 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1529,6 +1529,13 @@ export const ru: TranslationResources = { archiveFinishedAction: "Архивировать завершенные субагенты", archiveFinishedTooltip: "Архивировать завершенные", }, + heartbeats: { + trackLabel: "{{count}} пульс", + trackLabelPlural: "Пульсов: {{count}}", + openAction: "{{title}}, {{cadence}}", + deleteAction: "Удалить пульс {{title}}", + deleteTooltip: "Удалить пульс", + }, panels: { draft: { newAgent: "Новый агент", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d2e6fce42e..80277146d8 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1475,6 +1475,13 @@ export const zhCN: TranslationResources = { archiveFinishedAction: "归档已完成的 subagent", archiveFinishedTooltip: "归档已完成项", }, + heartbeats: { + trackLabel: "{{count}} 个心跳", + trackLabelPlural: "{{count}} 个心跳", + openAction: "{{title}},{{cadence}}", + deleteAction: "删除心跳 {{title}}", + deleteTooltip: "删除心跳", + }, panels: { draft: { newAgent: "新建 Agent", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 62373fb243..761c8eb757 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -88,6 +88,12 @@ import { useSubagentsForParent, } from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; +import { ComposerTracks } from "@/composer/tracks"; +import { HeartbeatsTrack } from "@/heartbeats/track"; +import { navigateToHeartbeat } from "@/heartbeats/navigate"; +import type { AgentHeartbeatRow } from "@/heartbeats/select"; +import { useAgentHeartbeats } from "@/heartbeats/use-agent-heartbeats"; +import { useDeleteHeartbeat } from "@/heartbeats/use-delete-heartbeat"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; @@ -1467,6 +1473,11 @@ function ActiveAgentComposer({ ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); + const heartbeatRows = useAgentHeartbeats({ serverId, agentId }); + const handleDeleteHeartbeat = useDeleteHeartbeat({ serverId }); + const handleOpenHeartbeat = useCallback((row: AgentHeartbeatRow) => { + navigateToHeartbeat({ serverId: row.serverId, scheduleId: row.scheduleId }); + }, []); const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({ serverId, parentAgentId: agentId, @@ -1557,14 +1568,27 @@ function ActiveAgentComposer({ return ( - + + {heartbeatRows.length > 0 ? ( + + ) : null} + {subagentRows.length > 0 ? ( + + ) : null} + { if (isExpired(schedule, now)) { return "expired"; } - if (isAgentTargetGone(input)) { - return "targetGone"; - } if (schedule.status === "completed") { return "finished"; } @@ -95,6 +98,16 @@ function deriveState(input: ResolveScheduleInput): ScheduleDerivedState { return "active"; } +// One badge, one truth. Order matters: expiry and a missing target are more +// informative than the raw "completed"/"paused" status, so they win. +function deriveState(input: ResolveScheduleInput): ScheduleDerivedState { + const lifecycle = deriveScheduleLifecycleState(input.schedule, input.now); + if (lifecycle !== "expired" && isAgentTargetGone(input)) { + return "targetGone"; + } + return lifecycle; +} + export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket { return state === "active" || state === "paused" ? "runnable" : "ended"; } diff --git a/packages/app/src/screens/schedules-screen-state.test.ts b/packages/app/src/screens/schedules-screen-state.test.ts index d0c8442a2d..477f405c5f 100644 --- a/packages/app/src/screens/schedules-screen-state.test.ts +++ b/packages/app/src/screens/schedules-screen-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveSchedulesScreenBodyState } from "./schedules-screen-state"; +import type { AggregatedSchedule } from "@/schedules/aggregated-schedules"; +import { resolveScheduleFocus, resolveSchedulesScreenBodyState } from "./schedules-screen-state"; describe("resolveSchedulesScreenBodyState", () => { it("routes failed loading state to the retry UI instead of the spinner", () => { @@ -11,3 +12,67 @@ describe("resolveSchedulesScreenBodyState", () => { ).toEqual({ kind: "load-error" }); }); }); + +const AGENT_ID = "00000000-0000-4000-8000-000000000001"; + +function makeSchedule(overrides: Partial = {}): AggregatedSchedule { + return { + id: "schedule-1", + name: "Watch the build", + prompt: "Check on the build", + cadence: { type: "every", everyMs: 900_000 }, + target: { type: "agent", agentId: AGENT_ID }, + status: "active", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + serverId: "host-1", + serverName: "Laptop", + ...overrides, + }; +} + +describe("resolveScheduleFocus", () => { + it("stays out of the way when the route names no schedule", () => { + expect( + resolveScheduleFocus({ + focus: null, + loadState: { status: "loaded", data: [makeSchedule()] }, + }), + ).toEqual({ kind: "none" }); + }); + + it("waits while schedules are still loading", () => { + expect( + resolveScheduleFocus({ + focus: { serverId: "host-1", scheduleId: "schedule-1" }, + loadState: { status: "loading" }, + }), + ).toEqual({ kind: "pending" }); + }); + + it("resolves the linked schedule on its own host", () => { + const mine = makeSchedule({ id: "schedule-1", serverId: "host-1" }); + const sameIdOtherHost = makeSchedule({ id: "schedule-1", serverId: "host-2" }); + + expect( + resolveScheduleFocus({ + focus: { serverId: "host-1", scheduleId: "schedule-1" }, + loadState: { status: "loaded", data: [sameIdOtherHost, mine] }, + }), + ).toEqual({ kind: "found", serverId: "host-1", schedule: mine }); + }); + + it("falls back to the list when the link is stale", () => { + expect( + resolveScheduleFocus({ + focus: { serverId: "host-1", scheduleId: "deleted" }, + loadState: { status: "loaded", data: [makeSchedule()] }, + }), + ).toEqual({ kind: "missing" }); + }); +}); diff --git a/packages/app/src/screens/schedules-screen-state.ts b/packages/app/src/screens/schedules-screen-state.ts index 43783c5fed..9a8ef2206a 100644 --- a/packages/app/src/screens/schedules-screen-state.ts +++ b/packages/app/src/screens/schedules-screen-state.ts @@ -1,4 +1,5 @@ import type { AggregateLoadState, AggregatedSchedule } from "@/schedules/aggregated-schedules"; +import type { ScheduleFocus } from "@/utils/host-routes"; export type SchedulesScreenBodyState = | { kind: "loading" } @@ -21,3 +22,34 @@ export function resolveSchedulesScreenBodyState(input: { } return { kind: "content" }; } + +export type ScheduleFocusResolution = + | { kind: "none" } + | { kind: "pending" } + | { kind: "missing" } + | { kind: "found"; serverId: string; schedule: AggregatedSchedule }; + +/** + * A deep link names one schedule on one host. Until the list loads there is + * nothing to open; once it has, an unmatched link is stale and the screen just + * shows the list. + */ +export function resolveScheduleFocus(input: { + focus: ScheduleFocus | null; + loadState: AggregateLoadState; +}): ScheduleFocusResolution { + if (!input.focus) { + return { kind: "none" }; + } + if (input.loadState.status !== "loaded") { + return { kind: "pending" }; + } + const schedule = input.loadState.data.find( + (candidate) => + candidate.serverId === input.focus?.serverId && candidate.id === input.focus.scheduleId, + ); + if (!schedule) { + return { kind: "missing" }; + } + return { kind: "found", serverId: schedule.serverId, schedule }; +} diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx index 76498053a9..20a41501c4 100644 --- a/packages/app/src/screens/schedules-screen.tsx +++ b/packages/app/src/screens/schedules-screen.tsx @@ -33,7 +33,8 @@ import { type ScheduleBucket, type ScheduleTargetAgent, } from "@/schedules/schedule-derivation"; -import { resolveSchedulesScreenBodyState } from "./schedules-screen-state"; +import { resolveScheduleFocus, resolveSchedulesScreenBodyState } from "./schedules-screen-state"; +import type { ScheduleFocus } from "@/utils/host-routes"; import { buildProjectNameByCwd, buildScheduleProjectTargets, @@ -52,17 +53,22 @@ const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: str const EMPTY_SCHEDULES: AggregatedSchedule[] = []; -export function SchedulesScreen(): ReactElement { +export interface SchedulesScreenProps { + /** Deep link target: open this exact schedule once the list has loaded. */ + focus?: ScheduleFocus | null; +} + +export function SchedulesScreen({ focus = null }: SchedulesScreenProps = {}): ReactElement { const isFocused = useIsFocused(); if (!isFocused) { return ; } - return ; + return ; } -function SchedulesScreenContent(): ReactElement { +function SchedulesScreenContent({ focus }: { focus: ScheduleFocus | null }): ReactElement { const { loadState, hostErrors, isError, refetch } = useSchedules(); const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES; const { agents } = useAggregatedAgents({ includeArchived: true }); @@ -92,6 +98,9 @@ function SchedulesScreenContent(): ReactElement { }, [hosts, runtime, runtimeVersion]); const [form, setForm] = useState({ mode: "closed" }); + // A deep link presents its schedule until the user acts on the form; after + // that their choice owns the sheet, so the link stops overriding it. + const [consumedFocusKey, setConsumedFocusKey] = useState(null); const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID); const [statusFilter, setStatusFilter] = useState("runnable"); @@ -104,11 +113,32 @@ function SchedulesScreenContent(): ReactElement { } }, [hosts, selectedHost]); - const openCreate = useCallback(() => setForm({ mode: "create" }), []); - const openEdit = useCallback((schedule: AggregatedSchedule) => { - setForm({ mode: "edit", serverId: schedule.serverId, schedule }); - }, []); - const closeForm = useCallback(() => setForm({ mode: "closed" }), []); + const focusKey = focus ? `${focus.serverId}:${focus.scheduleId}` : null; + const focusResolution = resolveScheduleFocus({ + focus: focusKey !== null && focusKey !== consumedFocusKey ? focus : null, + loadState, + }); + + const openCreate = useCallback(() => { + setConsumedFocusKey(focusKey); + setForm({ mode: "create" }); + }, [focusKey]); + const openEdit = useCallback( + (schedule: AggregatedSchedule) => { + setConsumedFocusKey(focusKey); + setForm({ mode: "edit", serverId: schedule.serverId, schedule }); + }, + [focusKey], + ); + const closeForm = useCallback(() => { + setConsumedFocusKey(focusKey); + setForm({ mode: "closed" }); + }, [focusKey]); + + const activeForm: FormState = + focusResolution.kind === "found" + ? { mode: "edit", serverId: focusResolution.serverId, schedule: focusResolution.schedule } + : form; const agentsByKey = useMemo(() => { const map = new Map(); @@ -182,11 +212,11 @@ function SchedulesScreenContent(): ReactElement { onEdit={openEdit} /> ); diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index b140b999a5..be8de9b903 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,12 +1,13 @@ -import { useCallback, useMemo, useState, type ReactElement } from "react"; -import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; +import { useCallback, useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Archive, Unlink } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; import { getProviderIcon } from "@/components/provider-icons"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; +import { + ComposerTrackActionButton, + ComposerTrackRow, + ComposerTrackSection, +} from "@/composer/tracks"; import { WorkspaceTabIcon, type WorkspaceTabPresentation, @@ -20,8 +21,6 @@ import { } from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedUnlink = withUnistyles(Unlink); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); @@ -29,7 +28,28 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +const ACTION_ICON_SIZE = 14; + +function renderArchiveIcon(isActive: boolean): ReactElement { + return ( + + ); +} + +function renderDetachIcon(isActive: boolean): ReactElement { + return ( + + ); +} + export interface SubagentsTrackProps { + /** Non-empty. The composer decides which tracks exist; a track always has rows. */ rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; @@ -38,8 +58,6 @@ export interface SubagentsTrackProps { onDetachSubagent?: (id: string) => void; } -const SUBAGENTS_LIST_MAX_HEIGHT = 200; - function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { const data = buildSubagentRowPresentationData(row); return { @@ -57,94 +75,43 @@ export function SubagentsTrack({ onArchiveSubagent, onArchiveFinished, onDetachSubagent, -}: SubagentsTrackProps): ReactElement | null { +}: SubagentsTrackProps): ReactElement { const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); + const canArchiveFinished = countFinishedSubagents(rows) > 0 && onArchiveFinished !== undefined; - const toggleExpanded = useCallback(() => { - setExpanded((current) => !current); - }, []); - - const surfaceStyle = useMemo( - () => [styles.surface, expanded && styles.surfaceExpanded], - [expanded], - ); - - const headerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.headerToggle, - (hovered || pressed) && styles.headerActive, - ], - [], - ); - const headerContainerStyle = useMemo( - () => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed], - [expanded], - ); - - if (rows.length === 0) { - return null; - } - - const headerLabel = formatHeaderLabel(rows); - const finishedCount = countFinishedSubagents(rows); + const renderHeaderAction = useCallback(() => { + if (!onArchiveFinished) { + return null; + } + return ( + + ); + }, [onArchiveFinished, t]); return ( - - - - - - {expanded ? ( - - ) : ( - - )} - - {headerLabel} - - - {finishedCount > 0 && onArchiveFinished ? ( - - - - ) : null} - - {expanded ? ( - - {rows.map((row) => ( - - ))} - - ) : null} - - - + + {rows.map((row) => ( + + ))} + ); } @@ -164,8 +131,6 @@ function SubagentsTrackRow({ onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); - const [hovered, setHovered] = useState(false); const presentation = useMemo(() => buildRowPresentation(row), [row]); const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; @@ -182,247 +147,46 @@ function SubagentsTrackRow({ const handleDetachPress = useCallback(() => { onDetachSubagent?.(row.id); }, [onDetachSubagent, row.id]); - const handlePointerEnter = useCallback(() => setHovered(true), []); - const handlePointerLeave = useCallback(() => setHovered(false), []); - const actionsAlwaysVisible = isNative || isCompact; - const actionsVisible = actionsAlwaysVisible || hovered; - return ( - // Wrapper View handles hover so moving the pointer between the row and - // the archive button doesn't drop the hover state — the same pattern - // used by sidebar workspace rows. - - - {({ pressed }) => ( - - - - {displayLabel} - - {presentation.subtitle ? ( - - {presentation.subtitle} - - ) : null} - {row.kind === "paseo" ? ( - - ) : null} - - )} - - + const renderLeading = useCallback( + () => , + [presentation], ); -} -function SubagentRowActions({ - rowId, - displayLabel, - visible, - onDetachPress, - onArchivePress, -}: { - rowId: string; - displayLabel: string; - visible: boolean; - onDetachPress?: () => void; - onArchivePress: () => void; -}): ReactElement { - const { t } = useTranslation(); - return ( - - {onDetachPress ? ( - ( + <> + {onDetachSubagent ? ( + + ) : null} + - ) : null} - - + + ), + [displayLabel, handleArchivePress, handleDetachPress, onDetachSubagent, row.id, t], ); -} -type SubagentActionIcon = "archive" | "detach"; - -function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement { - const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping; - if (icon === "detach") { - return ; - } - return ; -} - -function SubagentActionButton({ - accessibilityLabel, - testID, - tooltipLabel, - icon, - visible, - onPress, -}: { - accessibilityLabel: string; - testID: string; - tooltipLabel: string; - icon: SubagentActionIcon; - visible: boolean; - onPress: () => void; -}): ReactElement { return ( - - - - {({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)} - - - - {tooltipLabel} - - + ); } - -const styles = StyleSheet.create((theme) => ({ - outer: { - width: "100%", - alignItems: "center", - paddingHorizontal: theme.spacing[4], - }, - track: { - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - marginBottom: -theme.spacing[4], - }, - surface: { - alignSelf: "stretch", - backgroundColor: theme.colors.surface1, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, - borderBottomWidth: 0, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - overflow: "hidden", - }, - surfaceExpanded: { - paddingBottom: theme.spacing[4], - }, - header: { - flexDirection: "row", - alignItems: "center", - }, - headerToggle: { - flex: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingLeft: theme.spacing[3], - paddingRight: theme.spacing[1], - paddingVertical: theme.spacing[2], - }, - headerAction: { - paddingRight: theme.spacing[2], - }, - headerCollapsed: { - paddingBottom: theme.spacing[4], - }, - headerActive: { - backgroundColor: theme.colors.surface2, - }, - headerDivider: { - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - headerLabel: { - flexShrink: 1, - minWidth: 0, - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - scroll: { - maxHeight: SUBAGENTS_LIST_MAX_HEIGHT, - }, - scrollContent: { - paddingVertical: theme.spacing[1], - }, - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - rowActive: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - backgroundColor: theme.colors.surface2, - }, - rowLabel: { - flex: 1, - minWidth: 0, - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - // Keep provider context secondary and bounded so the task remains readable on compact screens. - rowSubtitle: { - flexShrink: 1, - minWidth: 0, - maxWidth: "45%", - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - actionClusterVisible: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - opacity: 1, - }, - actionClusterHidden: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - opacity: 0, - }, - actionButton: { - padding: theme.spacing[1], - alignItems: "center", - justifyContent: "center", - }, - tooltipText: { - fontSize: theme.fontSize.xs, - color: theme.colors.foreground, - }, -})); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 92a7240e3e..46b45bcdb6 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -7,6 +7,7 @@ import { buildNewWorkspaceRoute, buildOpenProjectRoute, resolveKnownHostRoute, + buildSchedulesRoute, buildSessionsRoute, buildSettingsAddHostRoute, buildProjectSettingsRoute, @@ -21,6 +22,7 @@ import { parseHostAgentRouteFromPathname, parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, + parseScheduleFocus, parseWorkspaceOpenIntent, stripHostWorkspaceRouteEchoSearch, } from "./host-routes"; @@ -241,6 +243,30 @@ describe("global routes", () => { }), ).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&draftId=draft-1"); }); + + it("buildSchedulesRoute returns the all-host Schedules route", () => { + expect(buildSchedulesRoute()).toBe("/schedules"); + }); + + it("buildSchedulesRoute identifies both host and schedule so ids cannot collide", () => { + expect(buildSchedulesRoute({ serverId: "host/1", scheduleId: "sch 1" })).toBe( + "/schedules?serverId=host%2F1&scheduleId=sch+1", + ); + }); + + it("parseScheduleFocus reads the focused schedule back out of route params", () => { + expect(parseScheduleFocus({ serverId: "host-1", scheduleId: "sch-1" })).toEqual({ + serverId: "host-1", + scheduleId: "sch-1", + }); + }); + + it("parseScheduleFocus ignores partial or blank params", () => { + expect(parseScheduleFocus({ serverId: "host-1" })).toBeNull(); + expect(parseScheduleFocus({ scheduleId: "sch-1" })).toBeNull(); + expect(parseScheduleFocus({ serverId: " ", scheduleId: "sch-1" })).toBeNull(); + expect(parseScheduleFocus({})).toBeNull(); + }); }); describe("host settings section slugs", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 3d8391eef2..63b5891bea 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -424,8 +424,35 @@ export function buildSessionsRoute() { return "/sessions" as const; } -export function buildSchedulesRoute() { - return "/schedules" as const; +/** + * One schedule on one daemon. Schedule ids are host-local, so a link that + * names only the schedule can resolve to the wrong row on a multi-host client. + */ +export interface ScheduleFocus { + serverId: string; + scheduleId: string; +} + +export function buildSchedulesRoute(focus?: ScheduleFocus) { + const serverId = trimNonEmpty(focus?.serverId); + const scheduleId = trimNonEmpty(focus?.scheduleId); + if (!serverId || !scheduleId) { + return "/schedules" as const; + } + const params = new URLSearchParams({ serverId, scheduleId }); + return `/schedules?${params.toString()}` as const; +} + +export function parseScheduleFocus(params: { + serverId?: NullableString; + scheduleId?: NullableString; +}): ScheduleFocus | null { + const serverId = trimNonEmpty(params.serverId); + const scheduleId = trimNonEmpty(params.scheduleId); + if (!serverId || !scheduleId) { + return null; + } + return { serverId, scheduleId }; } export function buildOpenProjectRoute() { diff --git a/packages/app/src/utils/schedule-format.ts b/packages/app/src/utils/schedule-format.ts index 8a239768f7..bb5330dee6 100644 --- a/packages/app/src/utils/schedule-format.ts +++ b/packages/app/src/utils/schedule-format.ts @@ -28,7 +28,10 @@ export function isNewAgentSchedule(schedule: ScheduleSummary): boolean { return schedule.target.type === "new-agent"; } -export function scheduleProductName(schedule: ScheduleSummary): "Heartbeat" | "Schedule" { +/** Paseo's vocabulary: an agent-target schedule is a Heartbeat, a new-agent one is a Schedule. */ +export type ScheduleProductName = "Heartbeat" | "Schedule"; + +export function scheduleProductName(schedule: ScheduleSummary): ScheduleProductName { return schedule.target.type === "agent" ? "Heartbeat" : "Schedule"; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 19b9e44494..0c7b68d498 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1216,6 +1216,12 @@ export async function createPaseoDaemon( createDirectoryWorkspace: createScheduleLocalWorkspaceExternal, createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal, archiveWorkspace: archiveScheduleWorkspaceExternal, + onHeartbeatActivityChanged: async (agentId) => { + const agent = await agentStorage.get(agentId); + if (agent?.workspaceId) { + await emitWorkspaceUpdatesExternal([agent.workspaceId]); + } + }, }); await scheduleService.start(); agentManager.setAgentArchivedCallback(async (agentId) => { diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index a7fad87beb..72c749da81 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -2614,6 +2614,57 @@ describe("ScheduleService", () => { expect((await service.inspect(newAgentSchedule.id)).status).toBe("active"); }); + test("lists only active, unexpired agent-target schedules as heartbeat activity", async () => { + const changedTargets: string[] = []; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + onHeartbeatActivityChanged: async (agentId) => { + changedTargets.push(agentId); + }, + }); + const activeAgentId = "11111111-1111-4111-8111-111111111111"; + const pausedAgentId = "22222222-2222-4222-8222-222222222222"; + const expiredAgentId = "33333333-3333-4333-8333-333333333333"; + + const active = await service.create({ + prompt: "watch active", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: activeAgentId }, + }); + const paused = await service.create({ + prompt: "watch paused", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: pausedAgentId }, + }); + await service.pause(paused.id); + await service.create({ + prompt: "watch expired", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: expiredAgentId }, + expiresAt: "2025-12-31T23:59:00.000Z", + }); + await service.create({ + prompt: "spawn instead", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + await expect(service.listActiveHeartbeatActivity()).resolves.toEqual([ + { agentId: activeAgentId, updatedAt: active.updatedAt }, + ]); + expect(changedTargets).toEqual([activeAgentId, pausedAgentId, pausedAgentId, expiredAgentId]); + + await service.delete(active.id); + expect(changedTargets.at(-1)).toBe(activeAgentId); + await expect(service.listActiveHeartbeatActivity()).resolves.toEqual([]); + }); + test("startup sweep completes agent-target schedules whose agent is gone", async () => { const missingAgentId = "44444444-4444-4444-8444-444444444444"; const archivedAgentId = "55555555-5555-4555-8555-555555555555"; diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 6a650ac92a..24f3b3fd48 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -228,6 +228,12 @@ export interface ScheduleServiceOptions { archiveWorkspace: (workspaceId: string) => Promise; now?: () => Date; runner?: (schedule: StoredSchedule, runId: string) => Promise; + onHeartbeatActivityChanged?: (agentId: string) => Promise; +} + +export interface ActiveHeartbeatActivity { + agentId: string; + updatedAt: string; } export class ScheduleService { @@ -248,6 +254,7 @@ export class ScheduleService { schedule: StoredSchedule, runId: string, ) => Promise; + private readonly onHeartbeatActivityChanged: (agentId: string) => Promise; private readonly runningScheduleIds = new Set(); private tickTimer: ReturnType | null = null; @@ -262,6 +269,7 @@ export class ScheduleService { this.archiveWorkspace = options.archiveWorkspace; this.now = options.now ?? (() => new Date()); this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId)); + this.onHeartbeatActivityChanged = options.onHeartbeatActivityChanged ?? (async () => {}); } async start(): Promise { @@ -289,11 +297,13 @@ export class ScheduleService { async create(input: CreateScheduleInput): Promise { const prompt = normalizePrompt(input.prompt); validateScheduleCadence(input.cadence); - return this.createScheduleRecord(input, { + const schedule = await this.createScheduleRecord(input, { name: trimOptionalName(input.name), prompt, target: input.target, }); + await this.notifyHeartbeatActivityChanged(schedule); + return schedule; } private async createScheduleRecord( @@ -335,11 +345,17 @@ export class ScheduleService { const prompt = normalizePrompt(input.prompt); validateScheduleCadence(input.cadence); if (name === null) { - return this.createScheduleRecord(input, { name, prompt, target: input.target }); + const schedule = await this.createScheduleRecord(input, { + name, + prompt, + target: input.target, + }); + await this.notifyHeartbeatActivityChanged(schedule); + return schedule; } const inputTarget = input.target; - return this.store.upsertByNameAndTarget(name, inputTarget, { + const schedule = await this.store.upsertByNameAndTarget(name, inputTarget, { create: async () => { return this.buildScheduleRecord(input, { name, prompt, target: inputTarget }); }, @@ -363,12 +379,28 @@ export class ScheduleService { }; }, }); + await this.notifyHeartbeatActivityChanged(schedule); + return schedule; } async list(): Promise { return this.store.list(); } + async listActiveHeartbeatActivity(): Promise { + const nowMs = this.now().getTime(); + return (await this.store.list()).flatMap((schedule) => { + if ( + schedule.target.type !== "agent" || + schedule.status !== "active" || + (schedule.expiresAt && new Date(schedule.expiresAt).getTime() <= nowMs) + ) { + return []; + } + return [{ agentId: schedule.target.agentId, updatedAt: schedule.updatedAt }]; + }); + } + async inspect(id: string): Promise { const schedule = await this.store.get(id); if (!schedule) { @@ -399,7 +431,9 @@ export class ScheduleService { updatedAt: now.toISOString(), }; }); - return requireSchedule(paused, id); + const schedule = requireSchedule(paused, id); + await this.notifyHeartbeatActivityChanged(schedule); + return schedule; } async resume(id: string): Promise { @@ -419,7 +453,9 @@ export class ScheduleService { updatedAt: now.toISOString(), }; }); - return requireSchedule(resumed, id); + const schedule = requireSchedule(resumed, id); + await this.notifyHeartbeatActivityChanged(schedule); + return schedule; } async update(input: UpdateScheduleInput): Promise { @@ -464,11 +500,15 @@ export class ScheduleService { return { ...updated, updatedAt: now.toISOString() }; }); - return requireSchedule(next, input.id); + const schedule = requireSchedule(next, input.id); + await this.notifyHeartbeatActivityChanged(schedule); + return schedule; } async delete(id: string): Promise { + const schedule = await this.inspect(id); await this.store.delete(id); + await this.notifyHeartbeatActivityChanged(schedule); } async completeForAgent(agentId: string): Promise { @@ -498,9 +538,29 @@ export class ScheduleService { ); } } + if (completed > 0) { + await this.notifyHeartbeatAgentActivityChanged(agentId); + } return completed; } + private async notifyHeartbeatActivityChanged(schedule: StoredSchedule): Promise { + if (schedule.target.type === "agent") { + await this.notifyHeartbeatAgentActivityChanged(schedule.target.agentId); + } + } + + private async notifyHeartbeatAgentActivityChanged(agentId: string): Promise { + try { + await this.onHeartbeatActivityChanged(agentId); + } catch (error) { + this.logger.warn( + { err: error, agentId }, + "Failed to emit heartbeat workspace activity update", + ); + } + } + private async completeScheduleForAgent( scheduleId: string, agentId: string, @@ -556,6 +616,7 @@ export class ScheduleService { } private async completeScheduleIfDue(scheduleId: string, now: Date): Promise { + let completed = false; const updated = await this.store.update(scheduleId, (schedule) => { if ( schedule.status !== "active" || @@ -564,9 +625,13 @@ export class ScheduleService { ) { return schedule; } + completed = true; return completeSchedule(schedule, now); }); - requireSchedule(updated, scheduleId); + const schedule = requireSchedule(updated, scheduleId); + if (completed) { + await this.notifyHeartbeatActivityChanged(schedule); + } } private async recoverInterruptedRuns(): Promise { @@ -663,7 +728,8 @@ export class ScheduleService { } private async sweepOrphanedSchedule(scheduleId: string, now: Date): Promise { - await this.store.update(scheduleId, async (schedule) => { + let completed = false; + const updated = await this.store.update(scheduleId, async (schedule) => { if (schedule.target.type !== "agent" || schedule.status === "completed") { return schedule; } @@ -671,8 +737,12 @@ export class ScheduleService { if (record && !record.archivedAt) { return schedule; } + completed = true; return completeSchedule(schedule, now); }); + if (updated && completed) { + await this.notifyHeartbeatActivityChanged(updated); + } } private async runSchedule( @@ -745,6 +815,7 @@ export class ScheduleService { targetGone: boolean; manual: boolean; }): Promise { + let heartbeatCompleted = false; const updatedSchedule = await this.store.update(params.scheduleId, (schedule) => { const now = this.now(); const completedRuns = schedule.runs.map((run) => @@ -770,6 +841,7 @@ export class ScheduleService { // The target is permanently gone; retrying only burns the schedule down to // its expiry, so complete it now regardless of manual/scheduled origin. updated = completeSchedule(updated, now); + heartbeatCompleted = schedule.target.type === "agent"; } else if (updated.status === "completed") { // Completed concurrently (e.g. the target agent was archived mid-run); // record the run outcome but leave the schedule terminal — don't advance. @@ -777,6 +849,7 @@ export class ScheduleService { // Manual one-shot runs do not advance the cadence or recompute completion. } else if (shouldCompleteSchedule(updated, now)) { updated = completeSchedule(updated, now); + heartbeatCompleted = schedule.target.type === "agent"; } else if (updated.status === "paused") { updated = { ...updated, @@ -796,7 +869,10 @@ export class ScheduleService { return updated; }); - requireSchedule(updatedSchedule, params.scheduleId); + const schedule = requireSchedule(updatedSchedule, params.scheduleId); + if (heartbeatCompleted) { + await this.notifyHeartbeatActivityChanged(schedule); + } } private async recordRunWorkspace(params: { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6d95d32a14..5538ad860c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -981,6 +981,7 @@ export class Session { workspaceRegistry: this.workspaceRegistry, listAgentPayloads: () => this.listAgentPayloads(), listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), + listHeartbeatActivity: () => scheduleService.listActiveHeartbeatActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input), diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 3f2a150b99..381d155234 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -5,7 +5,10 @@ import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./message import { WorkspaceDirectory } from "./workspace-directory.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; -import type { ProviderSubagentWorkspaceActivity } from "./workspace-directory.js"; +import type { + HeartbeatWorkspaceActivity, + ProviderSubagentWorkspaceActivity, +} from "./workspace-directory.js"; const NOW = "2026-03-01T12:00:00.000Z"; @@ -60,6 +63,7 @@ class WorkspaceStatus { private readonly agents: AgentSnapshotPayload[] = []; private readonly providerSubagents: ProviderSubagentWorkspaceActivity[] = []; + private readonly heartbeats: HeartbeatWorkspaceActivity[] = []; private readonly terminals: Array<{ cwd: string; workspaceId?: string; @@ -71,6 +75,7 @@ class WorkspaceStatus { workspaceRegistry: { list: async () => this.workspaces }, listAgentPayloads: async () => this.agents, listProviderSubagentActivity: async () => this.providerSubagents, + listHeartbeatActivity: async () => this.heartbeats, listTerminalActivityContributions: async () => this.terminals, isProviderVisibleToClient: () => true, buildWorkspaceDescriptor: async ({ workspace }) => ({ @@ -131,6 +136,10 @@ class WorkspaceStatus { this.providerSubagents.push(input); } + hasHeartbeat(input: HeartbeatWorkspaceActivity): void { + this.heartbeats.push(input); + } + hasWorktreeWorkspace(): void { this.workspaces.push(this.worktreeWorkspace); } @@ -425,6 +434,54 @@ describe("WorkspaceDirectory", () => { }); }); + test("active heartbeat contributes running without changing its idle target agent", async () => { + const workspace = new WorkspaceStatus(); + workspace.hasRootAgent({ id: "heartbeat-target", status: "idle" }); + workspace.hasHeartbeat({ + agentId: "heartbeat-target", + updatedAt: "2026-05-01T10:00:00.000Z", + }); + + const descriptor = await workspace.workspaceDescriptor(); + expect(descriptor.status).toBe("running"); + expect(descriptor.statusEnteredAt).toBe("2026-05-01T10:00:00.000Z"); + }); + + test("heartbeat follows explicit workspace ownership across same-cwd siblings", async () => { + const workspace = new WorkspaceStatus(); + workspace.hasSiblingWorkspaceSameCwd(); + workspace.hasStampedRootAgent({ + id: "heartbeat-target", + status: "idle", + workspaceId: "workspace-1-sibling", + }); + workspace.hasHeartbeat({ agentId: "heartbeat-target", updatedAt: NOW }); + + await expect(workspace.workspaceStatuses()).resolves.toEqual({ + "workspace-1": "done", + "workspace-1-sibling": "running", + }); + }); + + test("missing heartbeat targets contribute to no workspace", async () => { + const workspace = new WorkspaceStatus(); + workspace.hasHeartbeat({ agentId: "missing-agent", updatedAt: NOW }); + + await expect(workspace.workspaceStatus()).resolves.toBe("done"); + }); + + test("higher-priority agent state wins over heartbeat running", async () => { + const workspace = new WorkspaceStatus(); + workspace.hasRootAgent({ + id: "heartbeat-target", + status: "idle", + pendingPermissionCount: 1, + }); + workspace.hasHeartbeat({ agentId: "heartbeat-target", updatedAt: NOW }); + + await expect(workspace.workspaceStatus()).resolves.toBe("needs_input"); + }); + test("running cross-workspace subagent contributes to its own workspace", async () => { const workspace = new WorkspaceStatus(); @@ -560,6 +617,7 @@ describe("WorkspaceDirectory empty projects", () => { workspaceRegistry: { list: async () => input.workspaces }, listAgentPayloads: async () => [], listProviderSubagentActivity: async () => [], + listHeartbeatActivity: async () => [], listTerminalActivityContributions: async () => [], isProviderVisibleToClient: () => true, buildWorkspaceDescriptor: async ({ workspace }) => ({ diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index a50713e16f..3b4410dffb 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -68,6 +68,11 @@ export type ProviderSubagentWorkspaceActivity = Pick< "parentAgentId" | "status" | "updatedAt" >; +export interface HeartbeatWorkspaceActivity { + agentId: string; + updatedAt: string; +} + export interface WorkspaceDirectoryDeps { logger: pino.Logger; projectRegistry: { @@ -78,6 +83,7 @@ export interface WorkspaceDirectoryDeps { }; listAgentPayloads(): Promise; listProviderSubagentActivity(): Promise; + listHeartbeatActivity(): Promise; listTerminalActivityContributions(): Promise< Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> >; @@ -207,12 +213,14 @@ export class WorkspaceDirectory { const [ agents, providerSubagentActivity, + heartbeatActivity, persistedWorkspaces, persistedProjects, terminalContributions, ] = await Promise.all([ this.deps.listAgentPayloads(), this.deps.listProviderSubagentActivity(), + this.deps.listHeartbeatActivity(), this.deps.workspaceRegistry.list(), this.deps.projectRegistry.list(), this.deps.listTerminalActivityContributions(), @@ -274,6 +282,12 @@ export class WorkspaceDirectory { descriptorsByWorkspaceId, activityEntriesByWorkspaceId, }); + this.applyHeartbeatContributions({ + activeAgents, + heartbeatActivity, + descriptorsByWorkspaceId, + activityEntriesByWorkspaceId, + }); const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId( activeAgents, @@ -306,6 +320,32 @@ export class WorkspaceDirectory { return descriptorsByWorkspaceId; } + private applyHeartbeatContributions(params: { + activeAgents: AgentSnapshotPayload[]; + heartbeatActivity: HeartbeatWorkspaceActivity[]; + descriptorsByWorkspaceId: Map; + activityEntriesByWorkspaceId: Map; + }): void { + const activeAgentsById = new Map( + params.activeAgents.map((agent) => [agent.id, agent] as const), + ); + for (const heartbeat of params.heartbeatActivity) { + const workspaceId = activeAgentsById.get(heartbeat.agentId)?.workspaceId; + if (!workspaceId) continue; + const descriptor = params.descriptorsByWorkspaceId.get(workspaceId); + if (!descriptor) continue; + if ( + getWorkspaceStateBucketPriority("running") < + getWorkspaceStateBucketPriority(descriptor.status) + ) { + descriptor.status = "running"; + } + const entries = params.activityEntriesByWorkspaceId.get(workspaceId) ?? []; + entries.push({ bucket: "running", changedAtIso: heartbeat.updatedAt }); + params.activityEntriesByWorkspaceId.set(workspaceId, entries); + } + } + private applyProviderSubagentContributions(params: { activeAgents: AgentSnapshotPayload[]; providerSubagentActivity: ProviderSubagentWorkspaceActivity[];