mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +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 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:
|
- **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`).
|
- **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`).
|
- **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).
|
- **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`).
|
- **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`).
|
- **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.
|
- **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".
|
- **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".
|
- **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 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.
|
- **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".
|
- **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).
|
- **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) {
|
if (!box) {
|
||||||
throw new Error("Agent chat scroll container is not visible");
|
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.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
await page.mouse.wheel(0, input.deltaY);
|
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
|
await expect
|
||||||
.poll(async () => {
|
.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 { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { SchedulesScreen } from "@/screens/schedules-screen";
|
import { SchedulesScreen } from "@/screens/schedules-screen";
|
||||||
|
import { parseScheduleFocus } from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function SchedulesRoute() {
|
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 (
|
return (
|
||||||
<HostRouteBootstrapBoundary>
|
<HostRouteBootstrapBoundary>
|
||||||
<SchedulesScreen />
|
<SchedulesScreen focus={focus} />
|
||||||
</HostRouteBootstrapBoundary>
|
</HostRouteBootstrapBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
|||||||
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
||||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||||
import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
||||||
|
import { resolveDeleteScheduleDialog } from "@/schedules/delete-schedule";
|
||||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
@@ -113,13 +114,12 @@ function SchedulesTableRow({
|
|||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const productName = scheduleProductName(schedule);
|
const confirmed = await confirmDialog(
|
||||||
const confirmed = await confirmDialog({
|
resolveDeleteScheduleDialog({
|
||||||
title: `Delete ${productName.toLowerCase()}`,
|
productName: scheduleProductName(schedule),
|
||||||
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
|
title: resolveScheduleTitle(schedule),
|
||||||
confirmLabel: "Delete",
|
}),
|
||||||
destructive: true,
|
);
|
||||||
});
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
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;
|
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCHEDULE_REFRESH_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
export interface UseSchedulesResult {
|
export interface UseSchedulesResult {
|
||||||
loadState: AggregateLoadState<AggregatedSchedule>;
|
loadState: AggregateLoadState<AggregatedSchedule>;
|
||||||
|
dataUpdatedAt: number;
|
||||||
hostErrors: ScheduleHostError[];
|
hostErrors: ScheduleHostError[];
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
@@ -51,6 +54,7 @@ export function useSchedules(): UseSchedulesResult {
|
|||||||
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
|
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
|
||||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||||
dataShape: "list",
|
dataShape: "list",
|
||||||
|
refetchInterval: SCHEDULE_REFRESH_INTERVAL_MS,
|
||||||
staleTimeMs: 5_000,
|
staleTimeMs: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +69,7 @@ export function useSchedules(): UseSchedulesResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loadState,
|
loadState,
|
||||||
|
dataUpdatedAt: query.dataUpdatedAt,
|
||||||
hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [],
|
hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [],
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
|
|||||||
@@ -1495,6 +1495,16 @@ export const ar: TranslationResources = {
|
|||||||
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
|
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
|
||||||
archiveFinishedTooltip: "أرشفة المكتملين",
|
archiveFinishedTooltip: "أرشفة المكتملين",
|
||||||
},
|
},
|
||||||
|
heartbeats: {
|
||||||
|
trackLabel: "{{count}} نبضة",
|
||||||
|
trackLabelPlural: "{{count}} نبضات",
|
||||||
|
openAction: "{{title}}، {{cadence}}",
|
||||||
|
deleteAction: "حذف النبضة {{title}}",
|
||||||
|
deleteTooltip: "حذف النبضة",
|
||||||
|
hostUpdate: {
|
||||||
|
label: "قم بتحديث المضيف لعرض نشاط النبضات",
|
||||||
|
},
|
||||||
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "وكيل جديد",
|
newAgent: "وكيل جديد",
|
||||||
|
|||||||
@@ -1506,6 +1506,16 @@ export const en = {
|
|||||||
archiveFinishedAction: "Archive finished subagents",
|
archiveFinishedAction: "Archive finished subagents",
|
||||||
archiveFinishedTooltip: "Archive finished",
|
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: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "New Agent",
|
newAgent: "New Agent",
|
||||||
|
|||||||
@@ -1538,6 +1538,16 @@ export const es: TranslationResources = {
|
|||||||
archiveFinishedAction: "Archivar subagentes finalizados",
|
archiveFinishedAction: "Archivar subagentes finalizados",
|
||||||
archiveFinishedTooltip: "Archivar 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: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "Nuevo agente",
|
newAgent: "Nuevo agente",
|
||||||
|
|||||||
@@ -1542,6 +1542,16 @@ export const fr: TranslationResources = {
|
|||||||
archiveFinishedAction: "Archiver les sous-agents terminés",
|
archiveFinishedAction: "Archiver les sous-agents terminés",
|
||||||
archiveFinishedTooltip: "Archiver les 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: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "Nouvel agent",
|
newAgent: "Nouvel agent",
|
||||||
|
|||||||
@@ -1511,6 +1511,16 @@ export const ja: TranslationResources = {
|
|||||||
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
|
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
|
||||||
archiveFinishedTooltip: "完了した項目をアーカイブ",
|
archiveFinishedTooltip: "完了した項目をアーカイブ",
|
||||||
},
|
},
|
||||||
|
heartbeats: {
|
||||||
|
trackLabel: "ハートビート{{count}}件",
|
||||||
|
trackLabelPlural: "ハートビート{{count}}件",
|
||||||
|
openAction: "{{title}}、{{cadence}}",
|
||||||
|
deleteAction: "ハートビート{{title}}を削除",
|
||||||
|
deleteTooltip: "ハートビートを削除",
|
||||||
|
hostUpdate: {
|
||||||
|
label: "ハートビートのアクティビティを表示するにはホストを更新してください",
|
||||||
|
},
|
||||||
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "新しいエージェント",
|
newAgent: "新しいエージェント",
|
||||||
|
|||||||
@@ -1524,6 +1524,16 @@ export const ptBR: TranslationResources = {
|
|||||||
archiveFinishedAction: "Arquivar subagentes concluídos",
|
archiveFinishedAction: "Arquivar subagentes concluídos",
|
||||||
archiveFinishedTooltip: "Arquivar 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: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "Novo Agente",
|
newAgent: "Novo Agente",
|
||||||
|
|||||||
@@ -1529,6 +1529,16 @@ export const ru: TranslationResources = {
|
|||||||
archiveFinishedAction: "Архивировать завершенные субагенты",
|
archiveFinishedAction: "Архивировать завершенные субагенты",
|
||||||
archiveFinishedTooltip: "Архивировать завершенные",
|
archiveFinishedTooltip: "Архивировать завершенные",
|
||||||
},
|
},
|
||||||
|
heartbeats: {
|
||||||
|
trackLabel: "{{count}} пульс",
|
||||||
|
trackLabelPlural: "Пульсов: {{count}}",
|
||||||
|
openAction: "{{title}}, {{cadence}}",
|
||||||
|
deleteAction: "Удалить пульс {{title}}",
|
||||||
|
deleteTooltip: "Удалить пульс",
|
||||||
|
hostUpdate: {
|
||||||
|
label: "Обновите хост, чтобы показывать активность пульса",
|
||||||
|
},
|
||||||
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "Новый агент",
|
newAgent: "Новый агент",
|
||||||
|
|||||||
@@ -1475,6 +1475,16 @@ export const zhCN: TranslationResources = {
|
|||||||
archiveFinishedAction: "归档已完成的 subagent",
|
archiveFinishedAction: "归档已完成的 subagent",
|
||||||
archiveFinishedTooltip: "归档已完成项",
|
archiveFinishedTooltip: "归档已完成项",
|
||||||
},
|
},
|
||||||
|
heartbeats: {
|
||||||
|
trackLabel: "{{count}} 个心跳",
|
||||||
|
trackLabelPlural: "{{count}} 个心跳",
|
||||||
|
openAction: "{{title}},{{cadence}}",
|
||||||
|
deleteAction: "删除心跳 {{title}}",
|
||||||
|
deleteTooltip: "删除心跳",
|
||||||
|
hostUpdate: {
|
||||||
|
label: "更新 Host 以显示心跳活动",
|
||||||
|
},
|
||||||
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
newAgent: "新建 Agent",
|
newAgent: "新建 Agent",
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ import {
|
|||||||
useSubagentsForParent,
|
useSubagentsForParent,
|
||||||
} from "@/subagents";
|
} from "@/subagents";
|
||||||
import { SubagentsTrack } from "@/subagents/track";
|
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 { PendingPermission } from "@/types/shared";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||||
@@ -1467,6 +1473,14 @@ function ActiveAgentComposer({
|
|||||||
);
|
);
|
||||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||||
const handleDetachSubagent = useDetachSubagent({ 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({
|
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
|
||||||
serverId,
|
serverId,
|
||||||
parentAgentId: agentId,
|
parentAgentId: agentId,
|
||||||
@@ -1557,14 +1571,28 @@ function ActiveAgentComposer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||||
<SubagentsTrack
|
<ComposerTracks>
|
||||||
rows={subagentRows}
|
{heartbeatsTrack.kind === "none" ? null : (
|
||||||
onOpenSubagent={handleOpenSubagent}
|
<HeartbeatsTrack
|
||||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
key="heartbeats"
|
||||||
onArchiveSubagent={handleArchiveSubagent}
|
track={heartbeatsTrack}
|
||||||
onArchiveFinished={handleHideFinishedProviderSubagents}
|
onOpenHeartbeat={handleOpenHeartbeat}
|
||||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
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
|
<Composer
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverId={serverId}
|
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.
|
* The part of the derived state that the schedule record answers on its own:
|
||||||
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
|
* expiry, completion, pause. Callers that already know the target exists — the
|
||||||
const { schedule, now } = input;
|
* 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)) {
|
if (isExpired(schedule, now)) {
|
||||||
return "expired";
|
return "expired";
|
||||||
}
|
}
|
||||||
if (isAgentTargetGone(input)) {
|
|
||||||
return "targetGone";
|
|
||||||
}
|
|
||||||
if (schedule.status === "completed") {
|
if (schedule.status === "completed") {
|
||||||
return "finished";
|
return "finished";
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,16 @@ function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
|
|||||||
return "active";
|
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 {
|
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
|
||||||
return state === "active" || state === "paused" ? "runnable" : "ended";
|
return state === "active" || state === "paused" ? "runnable" : "ended";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("resolveSchedulesScreenBodyState", () => {
|
||||||
it("routes failed loading state to the retry UI instead of the spinner", () => {
|
it("routes failed loading state to the retry UI instead of the spinner", () => {
|
||||||
@@ -11,3 +12,67 @@ describe("resolveSchedulesScreenBodyState", () => {
|
|||||||
).toEqual({ kind: "load-error" });
|
).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 { AggregateLoadState, AggregatedSchedule } from "@/schedules/aggregated-schedules";
|
||||||
|
import type { ScheduleFocus } from "@/utils/host-routes";
|
||||||
|
|
||||||
export type SchedulesScreenBodyState =
|
export type SchedulesScreenBodyState =
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
@@ -21,3 +22,34 @@ export function resolveSchedulesScreenBodyState(input: {
|
|||||||
}
|
}
|
||||||
return { kind: "content" };
|
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 ScheduleBucket,
|
||||||
type ScheduleTargetAgent,
|
type ScheduleTargetAgent,
|
||||||
} from "@/schedules/schedule-derivation";
|
} 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 {
|
import {
|
||||||
buildProjectNameByCwd,
|
buildProjectNameByCwd,
|
||||||
buildScheduleProjectTargets,
|
buildScheduleProjectTargets,
|
||||||
@@ -52,17 +53,22 @@ const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: str
|
|||||||
|
|
||||||
const EMPTY_SCHEDULES: AggregatedSchedule[] = [];
|
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();
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
return <View style={styles.container} />;
|
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 { loadState, hostErrors, isError, refetch } = useSchedules();
|
||||||
const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES;
|
const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES;
|
||||||
const { agents } = useAggregatedAgents({ includeArchived: true });
|
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||||
@@ -92,6 +98,9 @@ function SchedulesScreenContent(): ReactElement {
|
|||||||
}, [hosts, runtime, runtimeVersion]);
|
}, [hosts, runtime, runtimeVersion]);
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
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 [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
||||||
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
|
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
|
||||||
|
|
||||||
@@ -104,11 +113,32 @@ function SchedulesScreenContent(): ReactElement {
|
|||||||
}
|
}
|
||||||
}, [hosts, selectedHost]);
|
}, [hosts, selectedHost]);
|
||||||
|
|
||||||
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
|
const focusKey = focus ? `${focus.serverId}:${focus.scheduleId}` : null;
|
||||||
const openEdit = useCallback((schedule: AggregatedSchedule) => {
|
const focusResolution = resolveScheduleFocus({
|
||||||
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
|
focus: focusKey !== null && focusKey !== consumedFocusKey ? focus : null,
|
||||||
}, []);
|
loadState,
|
||||||
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
|
});
|
||||||
|
|
||||||
|
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 agentsByKey = useMemo(() => {
|
||||||
const map = new Map<string, ScheduleTargetAgent>();
|
const map = new Map<string, ScheduleTargetAgent>();
|
||||||
@@ -182,11 +212,11 @@ function SchedulesScreenContent(): ReactElement {
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
/>
|
/>
|
||||||
<ScheduleFormSheet
|
<ScheduleFormSheet
|
||||||
serverId={form.mode === "edit" ? form.serverId : undefined}
|
serverId={activeForm.mode === "edit" ? activeForm.serverId : undefined}
|
||||||
visible={form.mode === "create" || form.mode === "edit"}
|
visible={activeForm.mode === "create" || activeForm.mode === "edit"}
|
||||||
onClose={closeForm}
|
onClose={closeForm}
|
||||||
mode={form.mode === "edit" ? "edit" : "create"}
|
mode={activeForm.mode === "edit" ? "edit" : "create"}
|
||||||
schedule={form.mode === "edit" ? form.schedule : undefined}
|
schedule={activeForm.mode === "edit" ? activeForm.schedule : undefined}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useMemo, type ReactElement } from "react";
|
||||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
|
import { Archive, Unlink } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import {
|
||||||
import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
ComposerTrackActionButton,
|
||||||
import { isNative } from "@/constants/platform";
|
ComposerTrackRow,
|
||||||
|
ComposerTrackSection,
|
||||||
|
} from "@/composer/tracks";
|
||||||
import {
|
import {
|
||||||
WorkspaceTabIcon,
|
WorkspaceTabIcon,
|
||||||
type WorkspaceTabPresentation,
|
type WorkspaceTabPresentation,
|
||||||
@@ -20,8 +21,6 @@ import {
|
|||||||
} from "./track-presentation";
|
} from "./track-presentation";
|
||||||
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
||||||
const ThemedUnlink = withUnistyles(Unlink);
|
const ThemedUnlink = withUnistyles(Unlink);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
@@ -29,7 +28,28 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
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 {
|
export interface SubagentsTrackProps {
|
||||||
|
/** Non-empty. The composer decides which tracks exist; a track always has rows. */
|
||||||
rows: SubagentRow[];
|
rows: SubagentRow[];
|
||||||
onOpenSubagent: (id: string) => void;
|
onOpenSubagent: (id: string) => void;
|
||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||||
@@ -38,8 +58,6 @@ export interface SubagentsTrackProps {
|
|||||||
onDetachSubagent?: (id: string) => void;
|
onDetachSubagent?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
|
||||||
|
|
||||||
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
||||||
const data = buildSubagentRowPresentationData(row);
|
const data = buildSubagentRowPresentationData(row);
|
||||||
return {
|
return {
|
||||||
@@ -57,94 +75,43 @@ export function SubagentsTrack({
|
|||||||
onArchiveSubagent,
|
onArchiveSubagent,
|
||||||
onArchiveFinished,
|
onArchiveFinished,
|
||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackProps): ReactElement | null {
|
}: SubagentsTrackProps): ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const canArchiveFinished = countFinishedSubagents(rows) > 0 && onArchiveFinished !== undefined;
|
||||||
|
|
||||||
const toggleExpanded = useCallback(() => {
|
const renderHeaderAction = useCallback(() => {
|
||||||
setExpanded((current) => !current);
|
if (!onArchiveFinished) {
|
||||||
}, []);
|
return null;
|
||||||
|
}
|
||||||
const surfaceStyle = useMemo(
|
return (
|
||||||
() => [styles.surface, expanded && styles.surfaceExpanded],
|
<ComposerTrackActionButton
|
||||||
[expanded],
|
accessibilityLabel={t("subagents.archiveFinishedAction")}
|
||||||
);
|
testID="subagents-track-archive-finished"
|
||||||
|
tooltipLabel={t("subagents.archiveFinishedTooltip")}
|
||||||
const headerStyle = useCallback(
|
renderIcon={renderArchiveIcon}
|
||||||
({ hovered, pressed }: PressableStateCallbackType) => [
|
onPress={onArchiveFinished}
|
||||||
styles.headerToggle,
|
/>
|
||||||
(hovered || pressed) && styles.headerActive,
|
);
|
||||||
],
|
}, [onArchiveFinished, t]);
|
||||||
[],
|
|
||||||
);
|
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.outer} testID="subagents-track">
|
<ComposerTrackSection
|
||||||
<View style={styles.track}>
|
label={formatHeaderLabel(rows)}
|
||||||
<View style={surfaceStyle}>
|
testID="subagents-track"
|
||||||
<View style={headerContainerStyle}>
|
headerTestID="subagents-track-header"
|
||||||
<Pressable
|
renderHeaderAction={canArchiveFinished ? renderHeaderAction : undefined}
|
||||||
accessibilityRole="button"
|
>
|
||||||
accessibilityLabel={headerLabel}
|
{rows.map((row) => (
|
||||||
testID="subagents-track-header"
|
<SubagentsTrackRow
|
||||||
onPress={toggleExpanded}
|
key={row.id}
|
||||||
style={headerStyle}
|
row={row}
|
||||||
>
|
onOpenSubagent={onOpenSubagent}
|
||||||
{expanded ? (
|
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||||
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
|
onArchiveSubagent={onArchiveSubagent}
|
||||||
) : (
|
onDetachSubagent={onDetachSubagent}
|
||||||
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
|
/>
|
||||||
)}
|
))}
|
||||||
<Text style={styles.headerLabel} numberOfLines={1}>
|
</ComposerTrackSection>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +131,6 @@ function SubagentsTrackRow({
|
|||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackRowProps): ReactElement {
|
}: SubagentsTrackRowProps): ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCompact = useIsCompactFormFactor();
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
const presentation = useMemo(() => buildRowPresentation(row), [row]);
|
const presentation = useMemo(() => buildRowPresentation(row), [row]);
|
||||||
const displayLabel =
|
const displayLabel =
|
||||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||||
@@ -182,247 +147,46 @@ function SubagentsTrackRow({
|
|||||||
const handleDetachPress = useCallback(() => {
|
const handleDetachPress = useCallback(() => {
|
||||||
onDetachSubagent?.(row.id);
|
onDetachSubagent?.(row.id);
|
||||||
}, [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 (
|
const renderLeading = useCallback(
|
||||||
// Wrapper View handles hover so moving the pointer between the row and
|
() => <WorkspaceTabIcon presentation={presentation} />,
|
||||||
// the archive button doesn't drop the hover state — the same pattern
|
[presentation],
|
||||||
// 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>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function SubagentRowActions({
|
const renderActions = useCallback(
|
||||||
rowId,
|
() => (
|
||||||
displayLabel,
|
<>
|
||||||
visible,
|
{onDetachSubagent ? (
|
||||||
onDetachPress,
|
<ComposerTrackActionButton
|
||||||
onArchivePress,
|
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
||||||
}: {
|
testID={`subagents-track-detach-${row.id}`}
|
||||||
rowId: string;
|
tooltipLabel={t("subagents.detachTooltip")}
|
||||||
displayLabel: string;
|
renderIcon={renderDetachIcon}
|
||||||
visible: boolean;
|
onPress={handleDetachPress}
|
||||||
onDetachPress?: () => void;
|
/>
|
||||||
onArchivePress: () => void;
|
) : null}
|
||||||
}): ReactElement {
|
<ComposerTrackActionButton
|
||||||
const { t } = useTranslation();
|
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||||
return (
|
testID={`subagents-track-archive-${row.id}`}
|
||||||
<View
|
tooltipLabel={t("subagents.archiveTooltip")}
|
||||||
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
|
renderIcon={renderArchiveIcon}
|
||||||
pointerEvents={visible ? "auto" : "none"}
|
onPress={handleArchivePress}
|
||||||
>
|
|
||||||
{onDetachPress ? (
|
|
||||||
<SubagentActionButton
|
|
||||||
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
|
||||||
testID={`subagents-track-detach-${rowId}`}
|
|
||||||
tooltipLabel={t("subagents.detachTooltip")}
|
|
||||||
icon="detach"
|
|
||||||
visible={visible}
|
|
||||||
onPress={onDetachPress}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
</>
|
||||||
<SubagentActionButton
|
),
|
||||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
[displayLabel, handleArchivePress, handleDetachPress, onDetachSubagent, row.id, t],
|
||||||
testID={`subagents-track-archive-${rowId}`}
|
|
||||||
tooltipLabel={t("subagents.archiveTooltip")}
|
|
||||||
icon="archive"
|
|
||||||
visible={visible}
|
|
||||||
onPress={onArchivePress}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<ComposerTrackRow
|
||||||
<TooltipTrigger asChild disabled={!visible}>
|
accessibilityRole="button"
|
||||||
<Pressable
|
accessibilityLabel={displayLabel}
|
||||||
accessibilityRole="button"
|
testID={`subagents-track-row-${row.id}`}
|
||||||
accessibilityLabel={accessibilityLabel}
|
onPress={handlePress}
|
||||||
testID={testID}
|
renderLeading={renderLeading}
|
||||||
onPress={onPress}
|
label={displayLabel}
|
||||||
style={styles.actionButton}
|
secondary={presentation.subtitle}
|
||||||
hitSlop={8}
|
actions={row.kind === "paseo" ? renderActions : undefined}
|
||||||
>
|
/>
|
||||||
{({ hovered, pressed }) => renderSubagentActionIcon(icon, 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,
|
|
||||||
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,
|
buildNewWorkspaceRoute,
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
resolveKnownHostRoute,
|
resolveKnownHostRoute,
|
||||||
|
buildSchedulesRoute,
|
||||||
buildSessionsRoute,
|
buildSessionsRoute,
|
||||||
buildSettingsAddHostRoute,
|
buildSettingsAddHostRoute,
|
||||||
buildProjectSettingsRoute,
|
buildProjectSettingsRoute,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceOpenIntentFromPathname,
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
parseScheduleFocus,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
stripHostWorkspaceRouteEchoSearch,
|
stripHostWorkspaceRouteEchoSearch,
|
||||||
} from "./host-routes";
|
} from "./host-routes";
|
||||||
@@ -241,6 +243,30 @@ describe("global routes", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&draftId=draft-1");
|
).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", () => {
|
describe("host settings section slugs", () => {
|
||||||
|
|||||||
@@ -424,8 +424,35 @@ export function buildSessionsRoute() {
|
|||||||
return "/sessions" as const;
|
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() {
|
export function buildOpenProjectRoute() {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export function isNewAgentSchedule(schedule: ScheduleSummary): boolean {
|
|||||||
return schedule.target.type === "new-agent";
|
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";
|
return schedule.target.type === "agent" ? "Heartbeat" : "Schedule";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const GitPullRequestClosed = StubIcon;
|
|||||||
export const GitPullRequestDraft = StubIcon;
|
export const GitPullRequestDraft = StubIcon;
|
||||||
export const Globe = StubIcon;
|
export const Globe = StubIcon;
|
||||||
export const Heart = StubIcon;
|
export const Heart = StubIcon;
|
||||||
|
export const HeartPulse = StubIcon;
|
||||||
export const History = StubIcon;
|
export const History = StubIcon;
|
||||||
export const Home = StubIcon;
|
export const Home = StubIcon;
|
||||||
export const Image = StubIcon;
|
export const Image = StubIcon;
|
||||||
|
|||||||
@@ -307,6 +307,22 @@ describe("agent detach RPC", () => {
|
|||||||
expect(parsed.features?.agentDetach).toBe(true);
|
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", () => {
|
test("parses the workspace-targeted session import feature gate", () => {
|
||||||
const parsed = parseServerInfoStatusPayload({
|
const parsed = parseServerInfoStatusPayload({
|
||||||
status: "server_info",
|
status: "server_info",
|
||||||
|
|||||||
@@ -2919,6 +2919,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
workspaceScriptManagement: z.boolean().optional(),
|
workspaceScriptManagement: z.boolean().optional(),
|
||||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||||
projectCustomIcon: z.boolean().optional(),
|
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(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1216,6 +1216,12 @@ export async function createPaseoDaemon(
|
|||||||
createDirectoryWorkspace: createScheduleLocalWorkspaceExternal,
|
createDirectoryWorkspace: createScheduleLocalWorkspaceExternal,
|
||||||
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
||||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||||
|
onHeartbeatActivityChanged: async (agentId) => {
|
||||||
|
const agent = await agentStorage.get(agentId);
|
||||||
|
if (agent?.workspaceId) {
|
||||||
|
await emitWorkspaceUpdatesExternal([agent.workspaceId]);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await scheduleService.start();
|
await scheduleService.start();
|
||||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||||
|
|||||||
@@ -2614,6 +2614,61 @@ describe("ScheduleService", () => {
|
|||||||
expect((await service.inspect(newAgentSchedule.id)).status).toBe("active");
|
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 () => {
|
test("startup sweep completes agent-target schedules whose agent is gone", async () => {
|
||||||
const missingAgentId = "44444444-4444-4444-8444-444444444444";
|
const missingAgentId = "44444444-4444-4444-8444-444444444444";
|
||||||
const archivedAgentId = "55555555-5555-4555-8555-555555555555";
|
const archivedAgentId = "55555555-5555-4555-8555-555555555555";
|
||||||
|
|||||||
@@ -228,6 +228,12 @@ export interface ScheduleServiceOptions {
|
|||||||
archiveWorkspace: (workspaceId: string) => Promise<void>;
|
archiveWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
||||||
|
onHeartbeatActivityChanged?: (agentId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveHeartbeatActivity {
|
||||||
|
agentId: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ScheduleService {
|
export class ScheduleService {
|
||||||
@@ -248,6 +254,7 @@ export class ScheduleService {
|
|||||||
schedule: StoredSchedule,
|
schedule: StoredSchedule,
|
||||||
runId: string,
|
runId: string,
|
||||||
) => Promise<ScheduleExecutionResult>;
|
) => Promise<ScheduleExecutionResult>;
|
||||||
|
private readonly onHeartbeatActivityChanged: (agentId: string) => Promise<void>;
|
||||||
private readonly runningScheduleIds = new Set<string>();
|
private readonly runningScheduleIds = new Set<string>();
|
||||||
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
@@ -262,6 +269,7 @@ export class ScheduleService {
|
|||||||
this.archiveWorkspace = options.archiveWorkspace;
|
this.archiveWorkspace = options.archiveWorkspace;
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId));
|
this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId));
|
||||||
|
this.onHeartbeatActivityChanged = options.onHeartbeatActivityChanged ?? (async () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
@@ -289,11 +297,13 @@ export class ScheduleService {
|
|||||||
async create(input: CreateScheduleInput): Promise<StoredSchedule> {
|
async create(input: CreateScheduleInput): Promise<StoredSchedule> {
|
||||||
const prompt = normalizePrompt(input.prompt);
|
const prompt = normalizePrompt(input.prompt);
|
||||||
validateScheduleCadence(input.cadence);
|
validateScheduleCadence(input.cadence);
|
||||||
return this.createScheduleRecord(input, {
|
const schedule = await this.createScheduleRecord(input, {
|
||||||
name: trimOptionalName(input.name),
|
name: trimOptionalName(input.name),
|
||||||
prompt,
|
prompt,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
});
|
});
|
||||||
|
await this.notifyHeartbeatActivityChanged(schedule);
|
||||||
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createScheduleRecord(
|
private async createScheduleRecord(
|
||||||
@@ -335,11 +345,17 @@ export class ScheduleService {
|
|||||||
const prompt = normalizePrompt(input.prompt);
|
const prompt = normalizePrompt(input.prompt);
|
||||||
validateScheduleCadence(input.cadence);
|
validateScheduleCadence(input.cadence);
|
||||||
if (name === null) {
|
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;
|
const inputTarget = input.target;
|
||||||
return this.store.upsertByNameAndTarget(name, inputTarget, {
|
const schedule = await this.store.upsertByNameAndTarget(name, inputTarget, {
|
||||||
create: async () => {
|
create: async () => {
|
||||||
return this.buildScheduleRecord(input, { name, prompt, target: inputTarget });
|
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[]> {
|
async list(): Promise<StoredSchedule[]> {
|
||||||
return this.store.list();
|
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> {
|
async inspect(id: string): Promise<StoredSchedule> {
|
||||||
const schedule = await this.store.get(id);
|
const schedule = await this.store.get(id);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
@@ -399,7 +431,9 @@ export class ScheduleService {
|
|||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return requireSchedule(paused, id);
|
const schedule = requireSchedule(paused, id);
|
||||||
|
await this.notifyHeartbeatActivityChanged(schedule);
|
||||||
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resume(id: string): Promise<StoredSchedule> {
|
async resume(id: string): Promise<StoredSchedule> {
|
||||||
@@ -419,7 +453,9 @@ export class ScheduleService {
|
|||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return requireSchedule(resumed, id);
|
const schedule = requireSchedule(resumed, id);
|
||||||
|
await this.notifyHeartbeatActivityChanged(schedule);
|
||||||
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(input: UpdateScheduleInput): Promise<StoredSchedule> {
|
async update(input: UpdateScheduleInput): Promise<StoredSchedule> {
|
||||||
@@ -464,11 +500,17 @@ export class ScheduleService {
|
|||||||
|
|
||||||
return { ...updated, updatedAt: now.toISOString() };
|
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> {
|
async delete(id: string): Promise<void> {
|
||||||
|
const schedule = await this.store.get(id);
|
||||||
await this.store.delete(id);
|
await this.store.delete(id);
|
||||||
|
if (schedule) {
|
||||||
|
await this.notifyHeartbeatActivityChanged(schedule);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeForAgent(agentId: string): Promise<number> {
|
async completeForAgent(agentId: string): Promise<number> {
|
||||||
@@ -498,9 +540,29 @@ export class ScheduleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (completed > 0) {
|
||||||
|
await this.notifyHeartbeatAgentActivityChanged(agentId);
|
||||||
|
}
|
||||||
return completed;
|
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(
|
private async completeScheduleForAgent(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -556,6 +618,7 @@ export class ScheduleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async completeScheduleIfDue(scheduleId: string, now: Date): Promise<void> {
|
private async completeScheduleIfDue(scheduleId: string, now: Date): Promise<void> {
|
||||||
|
let completed = false;
|
||||||
const updated = await this.store.update(scheduleId, (schedule) => {
|
const updated = await this.store.update(scheduleId, (schedule) => {
|
||||||
if (
|
if (
|
||||||
schedule.status !== "active" ||
|
schedule.status !== "active" ||
|
||||||
@@ -564,9 +627,13 @@ export class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
|
completed = true;
|
||||||
return completeSchedule(schedule, now);
|
return completeSchedule(schedule, now);
|
||||||
});
|
});
|
||||||
requireSchedule(updated, scheduleId);
|
const schedule = requireSchedule(updated, scheduleId);
|
||||||
|
if (completed) {
|
||||||
|
await this.notifyHeartbeatActivityChanged(schedule);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async recoverInterruptedRuns(): Promise<void> {
|
private async recoverInterruptedRuns(): Promise<void> {
|
||||||
@@ -663,7 +730,8 @@ export class ScheduleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sweepOrphanedSchedule(scheduleId: string, now: Date): Promise<void> {
|
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") {
|
if (schedule.target.type !== "agent" || schedule.status === "completed") {
|
||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
@@ -671,8 +739,12 @@ export class ScheduleService {
|
|||||||
if (record && !record.archivedAt) {
|
if (record && !record.archivedAt) {
|
||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
|
completed = true;
|
||||||
return completeSchedule(schedule, now);
|
return completeSchedule(schedule, now);
|
||||||
});
|
});
|
||||||
|
if (updated && completed) {
|
||||||
|
await this.notifyHeartbeatActivityChanged(updated);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runSchedule(
|
private async runSchedule(
|
||||||
@@ -745,6 +817,7 @@ export class ScheduleService {
|
|||||||
targetGone: boolean;
|
targetGone: boolean;
|
||||||
manual: boolean;
|
manual: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
let heartbeatCompleted = false;
|
||||||
const updatedSchedule = await this.store.update(params.scheduleId, (schedule) => {
|
const updatedSchedule = await this.store.update(params.scheduleId, (schedule) => {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const completedRuns = schedule.runs.map((run) =>
|
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
|
// The target is permanently gone; retrying only burns the schedule down to
|
||||||
// its expiry, so complete it now regardless of manual/scheduled origin.
|
// its expiry, so complete it now regardless of manual/scheduled origin.
|
||||||
updated = completeSchedule(updated, now);
|
updated = completeSchedule(updated, now);
|
||||||
|
heartbeatCompleted = schedule.target.type === "agent";
|
||||||
} else if (updated.status === "completed") {
|
} else if (updated.status === "completed") {
|
||||||
// Completed concurrently (e.g. the target agent was archived mid-run);
|
// Completed concurrently (e.g. the target agent was archived mid-run);
|
||||||
// record the run outcome but leave the schedule terminal — don't advance.
|
// 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.
|
// Manual one-shot runs do not advance the cadence or recompute completion.
|
||||||
} else if (shouldCompleteSchedule(updated, now)) {
|
} else if (shouldCompleteSchedule(updated, now)) {
|
||||||
updated = completeSchedule(updated, now);
|
updated = completeSchedule(updated, now);
|
||||||
|
heartbeatCompleted = schedule.target.type === "agent";
|
||||||
} else if (updated.status === "paused") {
|
} else if (updated.status === "paused") {
|
||||||
updated = {
|
updated = {
|
||||||
...updated,
|
...updated,
|
||||||
@@ -796,7 +871,10 @@ export class ScheduleService {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
requireSchedule(updatedSchedule, params.scheduleId);
|
const schedule = requireSchedule(updatedSchedule, params.scheduleId);
|
||||||
|
if (heartbeatCompleted) {
|
||||||
|
await this.notifyHeartbeatActivityChanged(schedule);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async recordRunWorkspace(params: {
|
private async recordRunWorkspace(params: {
|
||||||
|
|||||||
@@ -981,6 +981,7 @@ export class Session {
|
|||||||
workspaceRegistry: this.workspaceRegistry,
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
listAgentPayloads: () => this.listAgentPayloads(),
|
listAgentPayloads: () => this.listAgentPayloads(),
|
||||||
listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(),
|
listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(),
|
||||||
|
listHeartbeatActivity: () => scheduleService.listActiveHeartbeatActivity(),
|
||||||
listTerminalActivityContributions: () => this.listTerminalActivityContributions(),
|
listTerminalActivityContributions: () => this.listTerminalActivityContributions(),
|
||||||
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
||||||
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
|||||||
agentManager: createStub<SessionOptions["agentManager"]>({
|
agentManager: createStub<SessionOptions["agentManager"]>({
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
listAgents: () => [],
|
listAgents: () => [],
|
||||||
|
listProviderSubagentActivity: () => [],
|
||||||
getAgent: () => null,
|
getAgent: () => null,
|
||||||
}),
|
}),
|
||||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||||
@@ -259,6 +260,9 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
|||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
}),
|
}),
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
|
scheduleService: createStub<SessionOptions["scheduleService"]>({
|
||||||
|
listActiveHeartbeatActivity: async () => [],
|
||||||
|
}),
|
||||||
mcpBaseUrl: null,
|
mcpBaseUrl: null,
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
|
|||||||
@@ -160,7 +160,9 @@ function createHarness(input: {
|
|||||||
}),
|
}),
|
||||||
filesystem: { isDirectory: async () => true },
|
filesystem: { isDirectory: async () => true },
|
||||||
chatService: createStub<SessionOptions["chatService"]>({}),
|
chatService: createStub<SessionOptions["chatService"]>({}),
|
||||||
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
|
scheduleService: createStub<SessionOptions["scheduleService"]>({
|
||||||
|
listActiveHeartbeatActivity: async () => [],
|
||||||
|
}),
|
||||||
loopService: createStub<SessionOptions["loopService"]>({}),
|
loopService: createStub<SessionOptions["loopService"]>({}),
|
||||||
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
|
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
|
||||||
subscribe: async () => ({
|
subscribe: async () => ({
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export function asChatService(): SessionOptions["chatService"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function asScheduleService(): SessionOptions["scheduleService"] {
|
export function asScheduleService(): SessionOptions["scheduleService"] {
|
||||||
return createStub<SessionOptions["scheduleService"]>({});
|
return createStub<SessionOptions["scheduleService"]>({
|
||||||
|
listActiveHeartbeatActivity: async () => [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asLoopService(): SessionOptions["loopService"] {
|
export function asLoopService(): SessionOptions["loopService"] {
|
||||||
|
|||||||
@@ -935,6 +935,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
|
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
|
||||||
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
|
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
|
||||||
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
|
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
|
||||||
|
expect(serverInfo.features?.heartbeatWorkspaceActivity).toBe(true);
|
||||||
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
|
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1601,6 +1601,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
workspaceScriptManagement: true,
|
workspaceScriptManagement: true,
|
||||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||||
projectCustomIcon: true,
|
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 { WorkspaceDirectory } from "./workspace-directory.js";
|
||||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
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";
|
const NOW = "2026-03-01T12:00:00.000Z";
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ class WorkspaceStatus {
|
|||||||
|
|
||||||
private readonly agents: AgentSnapshotPayload[] = [];
|
private readonly agents: AgentSnapshotPayload[] = [];
|
||||||
private readonly providerSubagents: ProviderSubagentWorkspaceActivity[] = [];
|
private readonly providerSubagents: ProviderSubagentWorkspaceActivity[] = [];
|
||||||
|
private readonly heartbeats: HeartbeatWorkspaceActivity[] = [];
|
||||||
private readonly terminals: Array<{
|
private readonly terminals: Array<{
|
||||||
cwd: string;
|
cwd: string;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
@@ -71,6 +75,7 @@ class WorkspaceStatus {
|
|||||||
workspaceRegistry: { list: async () => this.workspaces },
|
workspaceRegistry: { list: async () => this.workspaces },
|
||||||
listAgentPayloads: async () => this.agents,
|
listAgentPayloads: async () => this.agents,
|
||||||
listProviderSubagentActivity: async () => this.providerSubagents,
|
listProviderSubagentActivity: async () => this.providerSubagents,
|
||||||
|
listHeartbeatActivity: async () => this.heartbeats,
|
||||||
listTerminalActivityContributions: async () => this.terminals,
|
listTerminalActivityContributions: async () => this.terminals,
|
||||||
isProviderVisibleToClient: () => true,
|
isProviderVisibleToClient: () => true,
|
||||||
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
||||||
@@ -131,6 +136,10 @@ class WorkspaceStatus {
|
|||||||
this.providerSubagents.push(input);
|
this.providerSubagents.push(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasHeartbeat(input: HeartbeatWorkspaceActivity): void {
|
||||||
|
this.heartbeats.push(input);
|
||||||
|
}
|
||||||
|
|
||||||
hasWorktreeWorkspace(): void {
|
hasWorktreeWorkspace(): void {
|
||||||
this.workspaces.push(this.worktreeWorkspace);
|
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 () => {
|
test("running cross-workspace subagent contributes to its own workspace", async () => {
|
||||||
const workspace = new WorkspaceStatus();
|
const workspace = new WorkspaceStatus();
|
||||||
|
|
||||||
@@ -560,6 +617,7 @@ describe("WorkspaceDirectory empty projects", () => {
|
|||||||
workspaceRegistry: { list: async () => input.workspaces },
|
workspaceRegistry: { list: async () => input.workspaces },
|
||||||
listAgentPayloads: async () => [],
|
listAgentPayloads: async () => [],
|
||||||
listProviderSubagentActivity: async () => [],
|
listProviderSubagentActivity: async () => [],
|
||||||
|
listHeartbeatActivity: async () => [],
|
||||||
listTerminalActivityContributions: async () => [],
|
listTerminalActivityContributions: async () => [],
|
||||||
isProviderVisibleToClient: () => true,
|
isProviderVisibleToClient: () => true,
|
||||||
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export type ProviderSubagentWorkspaceActivity = Pick<
|
|||||||
"parentAgentId" | "status" | "updatedAt"
|
"parentAgentId" | "status" | "updatedAt"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export interface HeartbeatWorkspaceActivity {
|
||||||
|
agentId: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceDirectoryDeps {
|
export interface WorkspaceDirectoryDeps {
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
@@ -78,6 +83,7 @@ export interface WorkspaceDirectoryDeps {
|
|||||||
};
|
};
|
||||||
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
|
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
|
||||||
listProviderSubagentActivity(): Promise<ProviderSubagentWorkspaceActivity[]>;
|
listProviderSubagentActivity(): Promise<ProviderSubagentWorkspaceActivity[]>;
|
||||||
|
listHeartbeatActivity(): Promise<HeartbeatWorkspaceActivity[]>;
|
||||||
listTerminalActivityContributions(): Promise<
|
listTerminalActivityContributions(): Promise<
|
||||||
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
|
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
|
||||||
>;
|
>;
|
||||||
@@ -207,12 +213,14 @@ export class WorkspaceDirectory {
|
|||||||
const [
|
const [
|
||||||
agents,
|
agents,
|
||||||
providerSubagentActivity,
|
providerSubagentActivity,
|
||||||
|
heartbeatActivity,
|
||||||
persistedWorkspaces,
|
persistedWorkspaces,
|
||||||
persistedProjects,
|
persistedProjects,
|
||||||
terminalContributions,
|
terminalContributions,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.deps.listAgentPayloads(),
|
this.deps.listAgentPayloads(),
|
||||||
this.deps.listProviderSubagentActivity(),
|
this.deps.listProviderSubagentActivity(),
|
||||||
|
this.deps.listHeartbeatActivity(),
|
||||||
this.deps.workspaceRegistry.list(),
|
this.deps.workspaceRegistry.list(),
|
||||||
this.deps.projectRegistry.list(),
|
this.deps.projectRegistry.list(),
|
||||||
this.deps.listTerminalActivityContributions(),
|
this.deps.listTerminalActivityContributions(),
|
||||||
@@ -274,6 +282,12 @@ export class WorkspaceDirectory {
|
|||||||
descriptorsByWorkspaceId,
|
descriptorsByWorkspaceId,
|
||||||
activityEntriesByWorkspaceId,
|
activityEntriesByWorkspaceId,
|
||||||
});
|
});
|
||||||
|
this.applyHeartbeatContributions({
|
||||||
|
activeAgents,
|
||||||
|
heartbeatActivity,
|
||||||
|
descriptorsByWorkspaceId,
|
||||||
|
activityEntriesByWorkspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId(
|
const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId(
|
||||||
activeAgents,
|
activeAgents,
|
||||||
@@ -306,6 +320,32 @@ export class WorkspaceDirectory {
|
|||||||
return descriptorsByWorkspaceId;
|
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: {
|
private applyProviderSubagentContributions(params: {
|
||||||
activeAgents: AgentSnapshotPayload[];
|
activeAgents: AgentSnapshotPayload[];
|
||||||
providerSubagentActivity: ProviderSubagentWorkspaceActivity[];
|
providerSubagentActivity: ProviderSubagentWorkspaceActivity[];
|
||||||
|
|||||||
Reference in New Issue
Block a user