mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1066ebb891 | ||
|
|
f821f1eca4 | ||
|
|
b49abdc5dc | ||
|
|
082d9c0e4a | ||
|
|
d84c0a8420 | ||
|
|
721b6a10dc | ||
|
|
40ae6447ba | ||
|
|
c73b03a168 | ||
|
|
20789bcd13 | ||
|
|
7f4eb38fc0 | ||
|
|
644f5ee941 |
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "وكيل جديد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "新しいエージェント",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Новый агент",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user