Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 1066ebb891 test(app): remove wheel listener race 2026-08-03 13:53:13 +00:00
Mohamed Boudra f821f1eca4 test(heartbeats): expand tracks idempotently 2026-08-03 13:20:43 +00:00
Mohamed Boudra b49abdc5dc test(heartbeats): start expiry after visibility 2026-08-03 13:20:42 +00:00
Mohamed Boudra 082d9c0e4a test(heartbeats): match active workspace label 2026-08-03 13:20:42 +00:00
Mohamed Boudra d84c0a8420 fix(schedules): preserve idempotent deletion 2026-08-03 13:20:42 +00:00
Mohamed Boudra 721b6a10dc fix(composer): isolate web hover handlers 2026-08-03 13:20:42 +00:00
Mohamed Boudra 40ae6447ba test(server): stub workspace activity dependencies
Give generic session schedule fixtures an explicit empty heartbeat activity result and declare both activity dependencies in the git-watch fixture. This preserves strict stubs while covering the workspace descriptor inputs introduced by heartbeat projection.
2026-08-03 13:20:42 +00:00
Mohamed Boudra c73b03a168 fix(heartbeats): gate workspace activity by host
Advertise heartbeat workspace projection as an optional daemon capability and gate the agent track at its feature boundary. Older hosts still run heartbeat prompts, but now show an update-host link instead of heartbeat rows that conflict with a done workspace.\n\nCover optional wire parsing, daemon advertisement, supported and unsupported track selection, and the update-host presentation.
2026-08-03 13:20:42 +00:00
Mohamed Boudra 20789bcd13 fix(composer): restore hover-gated track actions
Keep row actions mounted but hidden on wide web layouts until the plain row wrapper is hovered. Native and compact layouts keep controls visible, preserving the established subagent behavior in the shared track component.
2026-08-03 13:20:13 +00:00
Mohamed Boudra 7f4eb38fc0 fix(heartbeats): refresh mounted schedule state
Poll the shared schedules query while mounted and use each successful refresh timestamp when deriving heartbeat activity. This keeps externally created and expired heartbeats in sync without introducing a heartbeat-local timer.\n\nExtend the real-daemon browser flow to create heartbeats after the composer mounts and verify expiry removes the track and workspace activity.
2026-08-03 13:20:13 +00:00
Mohamed Boudra 644f5ee941 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.
2026-08-03 13:20:13 +00:00
53 changed files with 2228 additions and 413 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. A host without `heartbeatWorkspaceActivity` still runs the prompts but does not report them as workspace activity, so the track links to host settings instead of contradicting the workspace status.
- **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,71 @@
import { expect, test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
captureHeartbeatEvidence,
deleteHeartbeat,
expectHeartbeatDetails,
expectHeartbeatVisible,
expectWorkspaceDone,
expectWorkspaceRunning,
openHeartbeat,
openHeartbeatAndSubagentTracks,
openHeartbeatTrack,
removeSeededSubagent,
returnToAgentHeartbeat,
seedAgentWithHeartbeat,
} from "../support/helpers/heartbeats";
test("an agent heartbeat stays visible and keeps its workspace running", async ({
page,
}, testInfo) => {
test.setTimeout(150_000);
const scenario = await seedAgentWithHeartbeat();
try {
await openAgentRoute(page, {
workspaceId: scenario.workspace.workspaceId,
agentId: scenario.parent.id,
});
await expectWorkspaceDone(page);
const heartbeat = await scenario.createHeartbeat();
await expectWorkspaceRunning(page);
await openHeartbeatAndSubagentTracks(page, heartbeat, scenario.child.id);
await expectHeartbeatVisible(page, heartbeat);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-stacked-tracks");
await openHeartbeat(page, heartbeat);
await expectHeartbeatDetails(page, heartbeat);
await captureHeartbeatEvidence(page, testInfo, "heartbeat-schedule-details");
await removeSeededSubagent(scenario);
await returnToAgentHeartbeat(page);
await expectWorkspaceRunning(page);
await openHeartbeatTrack(page, heartbeat);
await deleteHeartbeat(page, heartbeat);
await expect
.poll(() => scenario.readHeartbeatExists(heartbeat.id), { 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");
const expiringHeartbeat = await scenario.createHeartbeat({ name: "Temporary watch" });
await expectWorkspaceRunning(page);
await openHeartbeatTrack(page, expiringHeartbeat);
await expectHeartbeatVisible(page, expiringHeartbeat);
await scenario.setHeartbeatExpiry(
expiringHeartbeat.id,
new Date(Date.now() + 20_000).toISOString(),
);
await expect(page.getByRole("button", { name: "1 heartbeat" })).toHaveCount(0, {
timeout: 30_000,
});
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
await expectWorkspaceDone(page);
} finally {
await scenario.cleanup().catch(() => undefined);
}
});
@@ -111,34 +111,27 @@ export async function scrollChatAwayFromBottom(
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
const wheelSettled = scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
root.addEventListener(
"wheel",
() => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
{ once: true },
);
}),
);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await wheelSettled;
await scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}),
);
await expect
.poll(async () => {
@@ -0,0 +1,221 @@
import { expect, type Locator, 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;
expiresAt?: string;
}): Promise<{ schedule: ScheduleSummary | null; error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
scheduleList(): Promise<{ schedules: ScheduleSummary[]; error: string | null }>;
scheduleUpdate(input: {
id: string;
expiresAt: string;
}): Promise<{ schedule: ScheduleSummary | null; error: string | null }>;
}
export interface SeededHeartbeat {
id: string;
name: string;
cadence: string;
}
export interface AgentHeartbeatScenario {
workspace: SeededWorkspace;
parent: { id: string; title: string };
child: { id: string; title: string };
createHeartbeat(input?: { name?: string; expiresAt?: string }): Promise<SeededHeartbeat>;
setHeartbeatExpiry(id: string, expiresAt: string): Promise<void>;
readWorkspaceStatus(): Promise<string | null>;
readHeartbeatExists(id: string): 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 heartbeatIds = new Set<string>();
return {
workspace,
parent: agents.parent,
child: agents.child,
createHeartbeat: async (input = {}) => {
const name = input.name ?? "Watch the build";
const created = await scheduleClient.scheduleCreate({
prompt: "Check the build and report any failures.",
name,
cadence: { type: "every", everyMs: 15 * 60_000 },
target: { type: "agent", agentId: agents.parent.id },
runOnCreate: false,
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
});
if (!created.schedule) {
throw new Error(created.error ?? "Failed to create heartbeat");
}
heartbeatIds.add(created.schedule.id);
return { id: created.schedule.id, name, cadence: "Every 15 minutes" };
},
setHeartbeatExpiry: async (id, expiresAt) => {
const updated = await scheduleClient.scheduleUpdate({ id, expiresAt });
if (!updated.schedule) {
throw new Error(updated.error ?? "Failed to update heartbeat expiry");
}
},
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 (id) => {
const result = await scheduleClient.scheduleList();
return result.schedules.some((schedule) => schedule.id === id);
},
readAgentStatuses: async () => {
const result = await workspace.client.fetchAgents({ scope: "active" });
return Object.fromEntries(result.entries.map(({ agent }) => [agent.id, agent.status]));
},
cleanup: async () => {
await Promise.all(
Array.from(heartbeatIds, (id) =>
scheduleClient.scheduleDelete({ id }).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(?:, Working)?$/ })
.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(?:, Working)?$/,
});
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,
heartbeat: SeededHeartbeat,
subagentId: string,
): Promise<void> {
await openHeartbeatTrack(page, heartbeat);
await ensureTrackExpanded(
page,
"1 subagent",
page.getByTestId(`subagents-track-row-${subagentId}`),
);
}
export async function openHeartbeatTrack(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
await ensureTrackExpanded(
page,
"1 heartbeat",
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
);
}
export async function expectHeartbeatVisible(
page: Page,
heartbeat: SeededHeartbeat,
): Promise<void> {
await expect(
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
).toBeVisible({ timeout: 30_000 });
}
export async function openHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
await page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }).click();
}
export async function expectHeartbeatDetails(
page: Page,
heartbeat: SeededHeartbeat,
): 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: SeededHeartbeat): Promise<void> {
const row = page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` });
await row.hover();
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" });
}
async function ensureTrackExpanded(page: Page, name: string, content: Locator): Promise<void> {
const disclosure = page.getByRole("button", { name });
await expect(disclosure).toBeVisible({ timeout: 30_000 });
if (!(await content.isVisible())) {
await disclosure.click();
}
await expect(content).toBeVisible({ timeout: 30_000 });
}
+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;
}
@@ -0,0 +1,126 @@
import { useCallback, useState, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
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;
}
interface ComposerTrackRowLayoutProps extends ComposerTrackRowProps {
isHovered: boolean;
showActions: boolean;
}
/**
* One row in a track. The row control and its actions are siblings so an
* action never activates the row's navigation.
*/
export function ComposerTrackRowLayout({
accessibilityRole,
accessibilityLabel,
testID,
onPress,
renderLeading,
label,
secondary,
actions,
isHovered,
showActions,
}: ComposerTrackRowLayoutProps): ReactElement {
const [pressed, setPressed] = useState(false);
const handlePressIn = useCallback(() => setPressed(true), []);
const handlePressOut = useCallback(() => setPressed(false), []);
return (
<View style={isHovered || 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={showActions ? styles.actionClusterVisible : styles.actionClusterHidden}
pointerEvents={showActions ? "auto" : "none"}
>
{actions()}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
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,
},
actionClusterVisible: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
opacity: 1,
},
actionClusterHidden: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
opacity: 0,
},
}));
+8
View File
@@ -0,0 +1,8 @@
import type { ReactElement } from "react";
import { ComposerTrackRowLayout, type ComposerTrackRowProps } from "./track-row-layout";
export type { ComposerTrackRowProps };
export function ComposerTrackRow(props: ComposerTrackRowProps): ReactElement {
return <ComposerTrackRowLayout {...props} isHovered={false} showActions />;
}
@@ -0,0 +1,20 @@
import { useCallback, useState, type ReactElement } from "react";
import { View } from "react-native";
import { useIsCompactFormFactor } from "@/constants/layout";
import { ComposerTrackRowLayout, type ComposerTrackRowProps } from "./track-row-layout";
export type { ComposerTrackRowProps };
export function ComposerTrackRow(props: ComposerTrackRowProps): ReactElement {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const showActions = isHovered || isCompact;
return (
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<ComposerTrackRowLayout {...props} isHovered={isHovered} showActions={showActions} />
</View>
);
}
+265
View File
@@ -0,0 +1,265 @@
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";
import { ComposerTrackRow, type ComposerTrackRowProps } from "./track-row";
export { ComposerTrackRow, type ComposerTrackRowProps };
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 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],
},
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);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { router, type Href } from "expo-router";
import { buildSchedulesRoute, buildSettingsHostSectionRoute } 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);
}
/**
* Open the host's own settings section, where the daemon version and its update
* control live. Where a user goes when a host is too old for a feature.
*/
export function navigateToHostUpdate(serverId: string): void {
router.push(buildSettingsHostSectionRoute(serverId, "host") as Href);
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import type { AggregatedSchedule } from "@/schedules/aggregated-schedules";
import { selectAgentHeartbeats, selectAgentHeartbeatsTrack } 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" })]));
});
});
function selectTrack(schedules: AggregatedSchedule[], showsHeartbeatActivity: boolean) {
return selectAgentHeartbeatsTrack({
schedules,
target: { serverId: "host-1", agentId: AGENT_ID },
now: NOW,
showsHeartbeatActivity,
});
}
describe("selectAgentHeartbeatsTrack", () => {
it("lists the heartbeats when the host reports their activity", () => {
const track = selectTrack([makeHeartbeat()], true);
expect(track.kind).toBe("heartbeats");
expect(track.kind === "heartbeats" ? track.rows.map((row) => row.scheduleId) : []).toEqual([
"schedule-1",
]);
});
it("asks for a host update instead of listing heartbeats whose activity the host hides", () => {
expect(selectTrack([makeHeartbeat()], false)).toEqual({ kind: "host-update-required" });
});
it("shows no track on a host without activity reporting when no heartbeat targets this agent", () => {
expect(selectTrack([makeHeartbeat({ status: "paused" })], false)).toEqual({ kind: "none" });
expect(selectTrack([], false)).toEqual({ kind: "none" });
});
it("shows no track when the host reports activity but nothing targets this agent", () => {
expect(selectTrack([], true)).toEqual({ kind: "none" });
});
});
+93
View File
@@ -0,0 +1,93 @@
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;
}
/**
* What the composer puts in the heartbeats lane: nothing, the heartbeats, or a
* prompt to update a host that does not report heartbeat activity.
*/
export type AgentHeartbeatsTrack =
| { kind: "none" }
| { kind: "heartbeats"; rows: AgentHeartbeatRow[] }
| { kind: "host-update-required" };
export interface SelectAgentHeartbeatsTrackInput extends SelectAgentHeartbeatsInput {
/**
* Whether the host reports heartbeat runs as workspace activity. Every host
* runs the prompts; an older one just does not surface a run, so the track's
* rows would sit still through every firing and read as nothing happening.
*/
showsHeartbeatActivity: boolean;
}
const EMPTY_HEARTBEAT_ROWS: AgentHeartbeatRow[] = [];
const NO_TRACK: AgentHeartbeatsTrack = { kind: "none" };
const HOST_UPDATE_REQUIRED_TRACK: AgentHeartbeatsTrack = { kind: "host-update-required" };
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;
}
/**
* The heartbeats track for one agent, capability included. This is the only
* place the heartbeat feature asks what the host reports, so nothing downstream
* has to branch on host version.
*/
export function selectAgentHeartbeatsTrack(
input: SelectAgentHeartbeatsTrackInput,
): AgentHeartbeatsTrack {
const rows = selectAgentHeartbeats(input);
if (rows.length === 0) {
return NO_TRACK;
}
return input.showsHeartbeatActivity ? { kind: "heartbeats", rows } : HOST_UPDATE_REQUIRED_TRACK;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import type { AgentHeartbeatRow } from "./select";
import { HeartbeatsTrack } from "./track";
// jsdom has no matchMedia; reanimated reads it at import time to check for
// reduced motion, and the tooltip on a track row's actions pulls it in.
vi.hoisted(() => {
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
window.matchMedia = () =>
({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
}) as unknown as MediaQueryList;
}
});
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
iconSize: { sm: 14, md: 16 },
opacity: { 50: 0.5 },
shadow: { md: {} },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
border: "#444",
borderAccent: "#555",
destructive: "#f00",
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }),
withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({
uniProps,
...rest
}: { uniProps?: (theme: unknown) => Record<string, unknown> } & Record<string, unknown>) =>
React.createElement(Component, { ...rest, ...(uniProps ? uniProps(theme) : {}) }),
}));
vi.stubGlobal("React", React);
function makeRow(overrides: Partial<AgentHeartbeatRow> = {}): AgentHeartbeatRow {
return {
key: "host-1:schedule-1",
serverId: "host-1",
scheduleId: "schedule-1",
title: "Watch the build",
cadence: "Every 15 minutes",
...overrides,
};
}
function renderTrack(track: React.ComponentProps<typeof HeartbeatsTrack>["track"]) {
const handlers = {
onOpenHeartbeat: vi.fn(),
onDeleteHeartbeat: vi.fn(),
onUpdateHost: vi.fn(),
};
render(<HeartbeatsTrack track={track} {...handlers} />);
return handlers;
}
describe("HeartbeatsTrack", () => {
// The copy assertions below are the English strings.
beforeAll(async () => {
await i18n.changeLanguage("en");
});
afterEach(cleanup);
it("lists the heartbeats and opens one when its row is pressed", () => {
const handlers = renderTrack({ kind: "heartbeats", rows: [makeRow()] });
fireEvent.click(screen.getByTestId("heartbeats-track-header"));
expect(screen.getByText("Watch the build")).toBeTruthy();
expect(screen.getByText("Every 15 minutes")).toBeTruthy();
fireEvent.click(screen.getByTestId("heartbeats-track-row-host-1:schedule-1"));
expect(handlers.onOpenHeartbeat).toHaveBeenCalledWith(makeRow());
});
it("asks for a host update instead of listing heartbeats whose activity the host hides", () => {
const handlers = renderTrack({ kind: "host-update-required" });
expect(screen.queryByTestId("heartbeats-track")).toBeNull();
expect(screen.queryByText("Watch the build")).toBeNull();
const updateRow = screen.getByTestId("heartbeats-track-host-update");
expect(updateRow.textContent).toContain("Update the host to show heartbeat activity");
fireEvent.click(updateRow);
expect(handlers.onUpdateHost).toHaveBeenCalledTimes(1);
expect(handlers.onOpenHeartbeat).not.toHaveBeenCalled();
});
});
+166
View File
@@ -0,0 +1,166 @@
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, AgentHeartbeatsTrack } 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 {
/** Never `none`. The composer decides which tracks exist; a track always has content. */
track: Exclude<AgentHeartbeatsTrack, { kind: "none" }>;
onOpenHeartbeat: (row: AgentHeartbeatRow) => void;
onDeleteHeartbeat: (row: AgentHeartbeatRow) => void;
onUpdateHost: () => void;
}
/**
* The heartbeats babysitting this agent — or, on a host that does not report
* their activity, the one row that says so.
*/
export function HeartbeatsTrack({
track,
onOpenHeartbeat,
onDeleteHeartbeat,
onUpdateHost,
}: HeartbeatsTrackProps): ReactElement {
if (track.kind === "host-update-required") {
return <HeartbeatsHostUpdateRow onUpdateHost={onUpdateHost} />;
}
return (
<HeartbeatsTrackSection
rows={track.rows}
onOpenHeartbeat={onOpenHeartbeat}
onDeleteHeartbeat={onDeleteHeartbeat}
/>
);
}
/**
* Each row names the heartbeat and how often it fires; opening one goes to it
* on Schedules, deleting one stops it.
*/
function HeartbeatsTrackSection({
rows,
onOpenHeartbeat,
onDeleteHeartbeat,
}: {
rows: AgentHeartbeatRow[];
onOpenHeartbeat: (row: AgentHeartbeatRow) => void;
onDeleteHeartbeat: (row: AgentHeartbeatRow) => void;
}): 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}
/>
);
}
/**
* Heartbeats are running on this agent, but the host does not report their runs
* as workspace activity. Listing them here would show rows that never move
* while the prompts fire, so the lane points at the update that makes the
* activity visible instead. The heartbeats themselves stay on Schedules.
*/
function HeartbeatsHostUpdateRow({ onUpdateHost }: { onUpdateHost: () => void }): ReactElement {
const { t } = useTranslation();
return (
<ComposerTrackRow
accessibilityRole="link"
accessibilityLabel={t("heartbeats.hostUpdate.label")}
testID="heartbeats-track-host-update"
onPress={onUpdateHost}
renderLeading={renderHeartbeatIcon}
label={t("heartbeats.hostUpdate.label")}
/>
);
}
@@ -0,0 +1,39 @@
import { useMemo } from "react";
import { useSchedules } from "@/hooks/use-schedules";
import type { AggregatedSchedule } from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
import { selectAgentHeartbeatsTrack, type AgentHeartbeatsTrack } from "./select";
const EMPTY_SCHEDULES: AggregatedSchedule[] = [];
/**
* The heartbeats track for 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.
*
* The host capability is read here and nowhere else, so the composer only ever
* sees a track to render.
*/
export function useAgentHeartbeatsTrack(target: {
serverId: string;
agentId: string;
}): AgentHeartbeatsTrack {
const { serverId, agentId } = target;
const { loadState, dataUpdatedAt } = useSchedules();
const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES;
// COMPAT(heartbeatWorkspaceActivity): added in v0.2.6, remove the gate after 2027-02-02 once daemon floor >= v0.2.6.
const showsHeartbeatActivity = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.heartbeatWorkspaceActivity === true,
);
return useMemo(
() =>
selectAgentHeartbeatsTrack({
schedules,
target: { serverId, agentId },
now: dataUpdatedAt || Date.now(),
showsHeartbeatActivity,
}),
[schedules, serverId, agentId, dataUpdatedAt, showsHeartbeatActivity],
);
}
@@ -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],
);
}
+5
View File
@@ -24,8 +24,11 @@ export function schedulesQueryKey(serverIds: readonly string[]) {
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
}
const SCHEDULE_REFRESH_INTERVAL_MS = 5_000;
export interface UseSchedulesResult {
loadState: AggregateLoadState<AggregatedSchedule>;
dataUpdatedAt: number;
hostErrors: ScheduleHostError[];
isError: boolean;
error: Error | null;
@@ -51,6 +54,7 @@ export function useSchedules(): UseSchedulesResult {
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
dataShape: "list",
refetchInterval: SCHEDULE_REFRESH_INTERVAL_MS,
staleTimeMs: 5_000,
});
@@ -65,6 +69,7 @@ export function useSchedules(): UseSchedulesResult {
return {
loadState,
dataUpdatedAt: query.dataUpdatedAt,
hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [],
isError: query.isError,
error: query.error,
+10
View File
@@ -1495,6 +1495,16 @@ export const ar: TranslationResources = {
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين",
},
heartbeats: {
trackLabel: "{{count}} نبضة",
trackLabelPlural: "{{count}} نبضات",
openAction: "{{title}}، {{cadence}}",
deleteAction: "حذف النبضة {{title}}",
deleteTooltip: "حذف النبضة",
hostUpdate: {
label: "قم بتحديث المضيف لعرض نشاط النبضات",
},
},
panels: {
draft: {
newAgent: "وكيل جديد",
+10
View File
@@ -1506,6 +1506,16 @@ 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",
hostUpdate: {
label: "Update the host to show heartbeat activity",
},
},
panels: {
draft: {
newAgent: "New Agent",
+10
View File
@@ -1538,6 +1538,16 @@ 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",
hostUpdate: {
label: "Actualiza el host para mostrar la actividad de los latidos",
},
},
panels: {
draft: {
newAgent: "Nuevo agente",
+10
View File
@@ -1542,6 +1542,16 @@ 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",
hostUpdate: {
label: "Mettez à jour l'hôte pour afficher l'activité des pulsations",
},
},
panels: {
draft: {
newAgent: "Nouvel agent",
+10
View File
@@ -1511,6 +1511,16 @@ export const ja: TranslationResources = {
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ",
},
heartbeats: {
trackLabel: "ハートビート{{count}}件",
trackLabelPlural: "ハートビート{{count}}件",
openAction: "{{title}}、{{cadence}}",
deleteAction: "ハートビート{{title}}を削除",
deleteTooltip: "ハートビートを削除",
hostUpdate: {
label: "ハートビートのアクティビティを表示するにはホストを更新してください",
},
},
panels: {
draft: {
newAgent: "新しいエージェント",
+10
View File
@@ -1524,6 +1524,16 @@ 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",
hostUpdate: {
label: "Atualize o host para mostrar a atividade dos batimentos",
},
},
panels: {
draft: {
newAgent: "Novo Agente",
+10
View File
@@ -1529,6 +1529,16 @@ export const ru: TranslationResources = {
archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные",
},
heartbeats: {
trackLabel: "{{count}} пульс",
trackLabelPlural: "Пульсов: {{count}}",
openAction: "{{title}}, {{cadence}}",
deleteAction: "Удалить пульс {{title}}",
deleteTooltip: "Удалить пульс",
hostUpdate: {
label: "Обновите хост, чтобы показывать активность пульса",
},
},
panels: {
draft: {
newAgent: "Новый агент",
+10
View File
@@ -1475,6 +1475,16 @@ export const zhCN: TranslationResources = {
archiveFinishedAction: "归档已完成的 subagent",
archiveFinishedTooltip: "归档已完成项",
},
heartbeats: {
trackLabel: "{{count}} 个心跳",
trackLabelPlural: "{{count}} 个心跳",
openAction: "{{title}},{{cadence}}",
deleteAction: "删除心跳 {{title}}",
deleteTooltip: "删除心跳",
hostUpdate: {
label: "更新 Host 以显示心跳活动",
},
},
panels: {
draft: {
newAgent: "新建 Agent",
+36 -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, navigateToHostUpdate } from "@/heartbeats/navigate";
import type { AgentHeartbeatRow } from "@/heartbeats/select";
import { useAgentHeartbeatsTrack } from "@/heartbeats/use-agent-heartbeats-track";
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,14 @@ function ActiveAgentComposer({
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const heartbeatsTrack = useAgentHeartbeatsTrack({ serverId, agentId });
const handleDeleteHeartbeat = useDeleteHeartbeat({ serverId });
const handleOpenHeartbeat = useCallback((row: AgentHeartbeatRow) => {
navigateToHeartbeat({ serverId: row.serverId, scheduleId: row.scheduleId });
}, []);
const handleUpdateHost = useCallback(() => {
navigateToHostUpdate(serverId);
}, [serverId]);
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
serverId,
parentAgentId: agentId,
@@ -1557,14 +1571,28 @@ 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>
{heartbeatsTrack.kind === "none" ? null : (
<HeartbeatsTrack
key="heartbeats"
track={heartbeatsTrack}
onOpenHeartbeat={handleOpenHeartbeat}
onDeleteHeartbeat={handleDeleteHeartbeat}
onUpdateHost={handleUpdateHost}
/>
)}
{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";
}
@@ -51,6 +51,7 @@ export const GitPullRequestClosed = StubIcon;
export const GitPullRequestDraft = StubIcon;
export const Globe = StubIcon;
export const Heart = StubIcon;
export const HeartPulse = StubIcon;
export const History = StubIcon;
export const Home = StubIcon;
export const Image = StubIcon;
+16
View File
@@ -307,6 +307,22 @@ describe("agent detach RPC", () => {
expect(parsed.features?.agentDetach).toBe(true);
});
test("parses the heartbeat workspace activity feature gate as optional", () => {
const capable = parseServerInfoStatusPayload({
status: "server_info",
serverId: "srv-capable",
features: { heartbeatWorkspaceActivity: true },
});
const legacy = parseServerInfoStatusPayload({
status: "server_info",
serverId: "srv-legacy",
features: {},
});
expect(capable?.features?.heartbeatWorkspaceActivity).toBe(true);
expect(legacy?.features?.heartbeatWorkspaceActivity).toBeUndefined();
});
test("parses the workspace-targeted session import feature gate", () => {
const parsed = parseServerInfoStatusPayload({
status: "server_info",
+2
View File
@@ -2919,6 +2919,8 @@ export const ServerInfoStatusPayloadSchema = z
workspaceScriptManagement: z.boolean().optional(),
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIcon: z.boolean().optional(),
// COMPAT(heartbeatWorkspaceActivity): added in v0.2.6, remove after 2027-02-02 once daemon floor >= v0.2.6.
heartbeatWorkspaceActivity: z.boolean().optional(),
})
.optional(),
})
+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,61 @@ 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([]);
const notificationsAfterDelete = changedTargets.length;
await expect(service.delete(active.id)).resolves.toBeUndefined();
expect(changedTargets).toHaveLength(notificationsAfterDelete);
});
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";
+87 -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,17 @@ 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.store.get(id);
await this.store.delete(id);
if (schedule) {
await this.notifyHeartbeatActivityChanged(schedule);
}
}
async completeForAgent(agentId: string): Promise<number> {
@@ -498,9 +540,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 +618,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 +627,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 +730,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 +739,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 +817,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 +843,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 +851,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 +871,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),
@@ -200,6 +200,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {},
listAgents: () => [],
listProviderSubagentActivity: () => [],
getAgent: () => null,
}),
agentStorage: createStub<SessionOptions["agentStorage"]>({
@@ -259,6 +260,9 @@ function createSessionForWorkspaceGitWatchTests(options?: {
dispose: () => {},
}),
workspaceGitService,
scheduleService: createStub<SessionOptions["scheduleService"]>({
listActiveHeartbeatActivity: async () => [],
}),
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -160,7 +160,9 @@ function createHarness(input: {
}),
filesystem: { isDirectory: async () => true },
chatService: createStub<SessionOptions["chatService"]>({}),
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
scheduleService: createStub<SessionOptions["scheduleService"]>({
listActiveHeartbeatActivity: async () => [],
}),
loopService: createStub<SessionOptions["loopService"]>({}),
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
subscribe: async () => ({
@@ -55,7 +55,9 @@ export function asChatService(): SessionOptions["chatService"] {
}
export function asScheduleService(): SessionOptions["scheduleService"] {
return createStub<SessionOptions["scheduleService"]>({});
return createStub<SessionOptions["scheduleService"]>({
listActiveHeartbeatActivity: async () => [],
});
}
export function asLoopService(): SessionOptions["loopService"] {
@@ -935,6 +935,7 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.heartbeatWorkspaceActivity).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
await server.close();
});
@@ -1601,6 +1601,8 @@ export class VoiceAssistantWebSocketServer {
workspaceScriptManagement: true,
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIcon: true,
// COMPAT(heartbeatWorkspaceActivity): added in v0.2.6, remove after 2027-02-02 once daemon floor >= v0.2.6.
heartbeatWorkspaceActivity: true,
},
};
}
@@ -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[];