feat(agents): surface heartbeat activity

Keep idle babysitting agents visible through a dedicated heartbeat track while projecting active heartbeat schedules into workspace activity. Agent lifecycle stays unchanged so heartbeat targets do not gain interrupt semantics.
This commit is contained in:
Mohamed Boudra
2026-08-03 13:20:13 +00:00
parent ed4fc08f78
commit 644f5ee941
39 changed files with 1761 additions and 385 deletions
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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 `<thing> 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 `<thing> 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).
@@ -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);
}
});
@@ -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<string | null>;
readHeartbeatExists(): Promise<boolean>;
readAgentStatuses(): Promise<Record<string, string>>;
cleanup(): Promise<void>;
}
export async function seedAgentWithHeartbeat(): Promise<AgentHeartbeatScenario> {
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<void> {
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<void> {
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<void> {
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<void> {
await expect(
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
).toBeVisible({ timeout: 30_000 });
}
export async function openHeartbeat(
page: Page,
heartbeat: AgentHeartbeatScenario["heartbeat"],
): Promise<void> {
await page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }).click();
}
export async function expectHeartbeatDetails(
page: Page,
heartbeat: AgentHeartbeatScenario["heartbeat"],
): Promise<void> {
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<void> {
await page.goBack();
await expect(page.getByRole("button", { name: "1 heartbeat" })).toBeVisible({
timeout: 30_000,
});
}
export async function removeSeededSubagent(scenario: AgentHeartbeatScenario): Promise<void> {
await scenario.workspace.client.archiveAgent(scenario.child.id);
}
export async function deleteHeartbeat(
page: Page,
heartbeat: AgentHeartbeatScenario["heartbeat"],
): Promise<void> {
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<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true });
await testInfo.attach(name, { path, contentType: "image/png" });
}
+9 -1
View File
@@ -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 (
<HostRouteBootstrapBoundary>
<SchedulesScreen />
<SchedulesScreen focus={focus} />
</HostRouteBootstrapBoundary>
);
}
@@ -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;
}
+367
View File
@@ -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 (
<View style={styles.outer} testID="composer-tracks">
<View style={styles.track}>
<View style={styles.surface}>
{sections.map((section, index) => (
<View
key={isValidElement(section) ? String(section.key) : index}
style={index === 0 ? undefined : styles.sectionDivided}
>
{section}
</View>
))}
</View>
</View>
</View>
);
}
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 (
<View testID={testID}>
<View style={expanded ? styles.headerExpanded : styles.header}>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={expandedAccessibilityState(expanded)}
testID={headerTestID}
onPress={toggleExpanded}
style={headerStyle}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{label}
</Text>
</Pressable>
{renderHeaderAction ? (
<View style={styles.headerAction}>{renderHeaderAction()}</View>
) : null}
</View>
{expanded ? (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
nestedScrollEnabled
>
{children}
</ScrollView>
) : null}
</View>
);
}
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 (
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut}>
{() => (
<View style={hovered || pressed ? styles.rowActive : styles.row}>
<Pressable
accessibilityRole={accessibilityRole}
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={styles.rowMain}
>
{renderLeading?.()}
<Text style={styles.rowLabel} numberOfLines={1}>
{label}
</Text>
{secondary ? (
<Text style={styles.rowSecondary} numberOfLines={1}>
{secondary}
</Text>
) : null}
</Pressable>
{actions ? <View style={styles.actionCluster}>{actions()}</View> : null}
</View>
)}
</Pressable>
);
}
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 (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={handlePress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderIcon(hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
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,
},
}));
@@ -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]);
});
});
@@ -0,0 +1,36 @@
import { resolveDeleteScheduleDialog } from "@/schedules/delete-schedule";
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
export interface DeleteHeartbeatDeps {
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
deleteHeartbeat: (input: { serverId: string; scheduleId: string }) => Promise<void>;
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<void> {
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);
}
}
+11
View File
@@ -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);
}
+121
View File
@@ -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> = {}): 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" })]));
});
});
+58
View File
@@ -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;
}
+117
View File
@@ -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 (
<ThemedTrash2
size={ACTION_ICON_SIZE}
uniProps={isActive ? destructiveColorMapping : foregroundMutedColorMapping}
/>
);
}
function renderHeartbeatIcon(): ReactElement {
return <ThemedHeartPulse size={ROW_ICON_SIZE} uniProps={foregroundMutedColorMapping} />;
}
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 (
<ComposerTrackSection
label={label}
testID="heartbeats-track"
headerTestID="heartbeats-track-header"
>
{rows.map((row) => (
<HeartbeatsTrackRow
key={row.key}
row={row}
onOpenHeartbeat={onOpenHeartbeat}
onDeleteHeartbeat={onDeleteHeartbeat}
/>
))}
</ComposerTrackSection>
);
}
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(
() => (
<ComposerTrackActionButton
accessibilityLabel={t("heartbeats.deleteAction", { title: row.title })}
testID={`heartbeats-track-delete-${row.key}`}
tooltipLabel={t("heartbeats.deleteTooltip")}
renderIcon={renderDeleteIcon}
onPress={handleDeletePress}
/>
),
[handleDeletePress, row.title, row.key, t],
);
return (
<ComposerTrackRow
// A heartbeat row leaves the agent for Schedules, so it is a link — which
// also keeps its accessible name out of the delete button's namespace.
accessibilityRole="link"
accessibilityLabel={t("heartbeats.openAction", { title: row.title, cadence: row.cadence })}
testID={`heartbeats-track-row-${row.key}`}
onPress={handlePress}
renderLeading={renderHeartbeatIcon}
label={row.title}
secondary={row.cadence}
actions={renderActions}
/>
);
}
@@ -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],
);
}
@@ -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],
);
}
+7
View File
@@ -1495,6 +1495,13 @@ export const ar: TranslationResources = {
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين",
},
heartbeats: {
trackLabel: "{{count}} نبضة",
trackLabelPlural: "{{count}} نبضات",
openAction: "{{title}}، {{cadence}}",
deleteAction: "حذف النبضة {{title}}",
deleteTooltip: "حذف النبضة",
},
panels: {
draft: {
newAgent: "وكيل جديد",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -1511,6 +1511,13 @@ export const ja: TranslationResources = {
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ",
},
heartbeats: {
trackLabel: "ハートビート{{count}}件",
trackLabelPlural: "ハートビート{{count}}件",
openAction: "{{title}}、{{cadence}}",
deleteAction: "ハートビート{{title}}を削除",
deleteTooltip: "ハートビートを削除",
},
panels: {
draft: {
newAgent: "新しいエージェント",
+7
View File
@@ -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",
+7
View File
@@ -1529,6 +1529,13 @@ export const ru: TranslationResources = {
archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные",
},
heartbeats: {
trackLabel: "{{count}} пульс",
trackLabelPlural: "Пульсов: {{count}}",
openAction: "{{title}}, {{cadence}}",
deleteAction: "Удалить пульс {{title}}",
deleteTooltip: "Удалить пульс",
},
panels: {
draft: {
newAgent: "Новый агент",
+7
View File
@@ -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",
+32 -8
View File
@@ -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 (
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onArchiveFinished={handleHideFinishedProviderSubagents}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
<ComposerTracks>
{heartbeatRows.length > 0 ? (
<HeartbeatsTrack
key="heartbeats"
rows={heartbeatRows}
onOpenHeartbeat={handleOpenHeartbeat}
onDeleteHeartbeat={handleDeleteHeartbeat}
/>
) : null}
{subagentRows.length > 0 ? (
<SubagentsTrack
key="subagents"
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onArchiveFinished={handleHideFinishedProviderSubagents}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
) : null}
</ComposerTracks>
<Composer
agentId={agentId}
serverId={serverId}
@@ -0,0 +1,23 @@
import type { ScheduleProductName } from "@/utils/schedule-format";
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
export interface ResolveDeleteScheduleDialogInput {
productName: ScheduleProductName;
title: string;
}
/**
* Deleting a schedule is one decision, so it reads the same wherever it is
* offered — the Schedules list and the agent heartbeat track both ask this.
*/
export function resolveDeleteScheduleDialog(
input: ResolveDeleteScheduleDialogInput,
): ConfirmDialogInput {
const productName = input.productName.toLowerCase();
return {
title: `Delete ${productName}`,
message: `Delete "${input.title}"? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,
};
}
@@ -76,16 +76,19 @@ function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
};
}
// 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 { schedule, now } = input;
/**
* The part of the derived state that the schedule record answers on its own:
* expiry, completion, pause. Callers that already know the target exists — the
* heartbeat track renders inside its own target agent — ask this directly
* instead of assembling an agent directory just to rule out `targetGone`.
*/
export function deriveScheduleLifecycleState(
schedule: ScheduleSummary,
now: number,
): Exclude<ScheduleDerivedState, "targetGone"> {
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";
}
@@ -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> = {}): 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" });
});
});
@@ -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<AggregatedSchedule>;
}): 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 };
}
+43 -13
View File
@@ -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 <View style={styles.container} />;
}
return <SchedulesScreenContent />;
return <SchedulesScreenContent focus={focus} />;
}
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<FormState>({ 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<string | null>(null);
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("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<string, ScheduleTargetAgent>();
@@ -182,11 +212,11 @@ function SchedulesScreenContent(): ReactElement {
onEdit={openEdit}
/>
<ScheduleFormSheet
serverId={form.mode === "edit" ? form.serverId : undefined}
visible={form.mode === "create" || form.mode === "edit"}
serverId={activeForm.mode === "edit" ? activeForm.serverId : undefined}
visible={activeForm.mode === "create" || activeForm.mode === "edit"}
onClose={closeForm}
mode={form.mode === "edit" ? "edit" : "create"}
schedule={form.mode === "edit" ? form.schedule : undefined}
mode={activeForm.mode === "edit" ? "edit" : "create"}
schedule={activeForm.mode === "edit" ? activeForm.schedule : undefined}
/>
</View>
);
+96 -332
View File
@@ -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 (
<ThemedArchive
size={ACTION_ICON_SIZE}
uniProps={isActive ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function renderDetachIcon(isActive: boolean): ReactElement {
return (
<ThemedUnlink
size={ACTION_ICON_SIZE}
uniProps={isActive ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
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 (
<ComposerTrackActionButton
accessibilityLabel={t("subagents.archiveFinishedAction")}
testID="subagents-track-archive-finished"
tooltipLabel={t("subagents.archiveFinishedTooltip")}
renderIcon={renderArchiveIcon}
onPress={onArchiveFinished}
/>
);
}, [onArchiveFinished, t]);
return (
<View style={styles.outer} testID="subagents-track">
<View style={styles.track}>
<View style={surfaceStyle}>
<View style={headerContainerStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
testID="subagents-track-header"
onPress={toggleExpanded}
style={headerStyle}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
{finishedCount > 0 && onArchiveFinished ? (
<View style={styles.headerAction}>
<SubagentActionButton
accessibilityLabel={t("subagents.archiveFinishedAction")}
testID="subagents-track-archive-finished"
tooltipLabel={t("subagents.archiveFinishedTooltip")}
icon="archive"
visible
onPress={onArchiveFinished}
/>
</View>
) : null}
</View>
{expanded ? (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
nestedScrollEnabled
>
{rows.map((row) => (
<SubagentsTrackRow
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
))}
</ScrollView>
) : null}
</View>
</View>
</View>
<ComposerTrackSection
label={formatHeaderLabel(rows)}
testID="subagents-track"
headerTestID="subagents-track-header"
renderHeaderAction={canArchiveFinished ? renderHeaderAction : undefined}
>
{rows.map((row) => (
<SubagentsTrackRow
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
))}
</ComposerTrackSection>
);
}
@@ -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.
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable
accessibilityRole="button"
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
onPress={handlePress}
>
{({ pressed }) => (
<View style={hovered || pressed ? styles.rowActive : styles.row}>
<WorkspaceTabIcon presentation={presentation} />
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
{presentation.subtitle ? (
<Text style={styles.rowSubtitle} numberOfLines={1}>
{presentation.subtitle}
</Text>
) : null}
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
) : null}
</View>
)}
</Pressable>
</View>
const renderLeading = useCallback(
() => <WorkspaceTabIcon presentation={presentation} />,
[presentation],
);
}
function SubagentRowActions({
rowId,
displayLabel,
visible,
onDetachPress,
onArchivePress,
}: {
rowId: string;
displayLabel: string;
visible: boolean;
onDetachPress?: () => void;
onArchivePress: () => void;
}): ReactElement {
const { t } = useTranslation();
return (
<View
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
pointerEvents={visible ? "auto" : "none"}
>
{onDetachPress ? (
<SubagentActionButton
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
testID={`subagents-track-detach-${rowId}`}
tooltipLabel={t("subagents.detachTooltip")}
icon="detach"
visible={visible}
onPress={onDetachPress}
const renderActions = useCallback(
() => (
<>
{onDetachSubagent ? (
<ComposerTrackActionButton
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
testID={`subagents-track-detach-${row.id}`}
tooltipLabel={t("subagents.detachTooltip")}
renderIcon={renderDetachIcon}
onPress={handleDetachPress}
/>
) : null}
<ComposerTrackActionButton
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${row.id}`}
tooltipLabel={t("subagents.archiveTooltip")}
renderIcon={renderArchiveIcon}
onPress={handleArchivePress}
/>
) : null}
<SubagentActionButton
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${rowId}`}
tooltipLabel={t("subagents.archiveTooltip")}
icon="archive"
visible={visible}
onPress={onArchivePress}
/>
</View>
</>
),
[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 <ThemedUnlink size={14} uniProps={uniProps} />;
}
return <ThemedArchive size={14} uniProps={uniProps} />;
}
function SubagentActionButton({
accessibilityLabel,
testID,
tooltipLabel,
icon,
visible,
onPress,
}: {
accessibilityLabel: string;
testID: string;
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
onPress: () => void;
}): ReactElement {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
<ComposerTrackRow
accessibilityRole="button"
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
onPress={handlePress}
renderLeading={renderLeading}
label={displayLabel}
secondary={presentation.subtitle}
actions={row.kind === "paseo" ? renderActions : undefined}
/>
);
}
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,
},
}));
@@ -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", () => {
+29 -2
View File
@@ -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() {
+4 -1
View File
@@ -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";
}
+6
View File
@@ -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) => {
@@ -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";
+85 -9
View File
@@ -228,6 +228,12 @@ export interface ScheduleServiceOptions {
archiveWorkspace: (workspaceId: string) => Promise<void>;
now?: () => Date;
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
onHeartbeatActivityChanged?: (agentId: string) => Promise<void>;
}
export interface ActiveHeartbeatActivity {
agentId: string;
updatedAt: string;
}
export class ScheduleService {
@@ -248,6 +254,7 @@ export class ScheduleService {
schedule: StoredSchedule,
runId: string,
) => Promise<ScheduleExecutionResult>;
private readonly onHeartbeatActivityChanged: (agentId: string) => Promise<void>;
private readonly runningScheduleIds = new Set<string>();
private tickTimer: ReturnType<typeof setInterval> | 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<void> {
@@ -289,11 +297,13 @@ export class ScheduleService {
async create(input: CreateScheduleInput): Promise<StoredSchedule> {
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<StoredSchedule[]> {
return this.store.list();
}
async listActiveHeartbeatActivity(): Promise<ActiveHeartbeatActivity[]> {
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<StoredSchedule> {
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<StoredSchedule> {
@@ -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<StoredSchedule> {
@@ -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<void> {
const schedule = await this.inspect(id);
await this.store.delete(id);
await this.notifyHeartbeatActivityChanged(schedule);
}
async completeForAgent(agentId: string): Promise<number> {
@@ -498,9 +538,29 @@ export class ScheduleService {
);
}
}
if (completed > 0) {
await this.notifyHeartbeatAgentActivityChanged(agentId);
}
return completed;
}
private async notifyHeartbeatActivityChanged(schedule: StoredSchedule): Promise<void> {
if (schedule.target.type === "agent") {
await this.notifyHeartbeatAgentActivityChanged(schedule.target.agentId);
}
}
private async notifyHeartbeatAgentActivityChanged(agentId: string): Promise<void> {
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<void> {
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<void> {
@@ -663,7 +728,8 @@ export class ScheduleService {
}
private async sweepOrphanedSchedule(scheduleId: string, now: Date): Promise<void> {
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<void> {
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: {
+1
View File
@@ -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),
@@ -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 }) => ({
@@ -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<AgentSnapshotPayload[]>;
listProviderSubagentActivity(): Promise<ProviderSubagentWorkspaceActivity[]>;
listHeartbeatActivity(): Promise<HeartbeatWorkspaceActivity[]>;
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<string, WorkspaceDescriptorPayload>;
activityEntriesByWorkspaceId: Map<string, WorkspaceBucketTimestampEntry[]>;
}): 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[];