mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
fix(heartbeats): gate workspace activity by host
Advertise heartbeat workspace projection as an optional daemon capability and gate the agent track at its feature boundary. Older hosts still run heartbeat prompts, but now show an update-host link instead of heartbeat rows that conflict with a done workspace.\n\nCover optional wire parsing, daemon advertisement, supported and unsupported track selection, and the update-host presentation.
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. 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.
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. The agent's **Heartbeats track** lists the ones still on a cadence for that agent on that host; opening a row goes to it on Schedules (`/schedules?serverId=…&scheduleId=…`, keyed by both because schedule ids are host-local). Paused, completed, and expired heartbeats stay on Schedules and leave the track. A host without `heartbeatWorkspaceActivity` still runs the prompts but does not report them as workspace activity, so the track links to host settings instead of contradicting the workspace status.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||
import { buildSchedulesRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
|
||||
/**
|
||||
* Open one heartbeat on the Schedules screen. The route names the host as well
|
||||
@@ -9,3 +9,11 @@ import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AggregatedSchedule } from "@/schedules/aggregated-schedules";
|
||||
import { selectAgentHeartbeats } from "./select";
|
||||
import { selectAgentHeartbeats, selectAgentHeartbeatsTrack } from "./select";
|
||||
|
||||
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
|
||||
const AGENT_ID = "00000000-0000-4000-8000-000000000001";
|
||||
@@ -119,3 +119,36 @@ describe("selectAgentHeartbeats", () => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,27 @@ export interface SelectAgentHeartbeatsInput {
|
||||
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}`;
|
||||
@@ -56,3 +76,18 @@ export function selectAgentHeartbeats(input: SelectAgentHeartbeatsInput): AgentH
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ComposerTrackSection,
|
||||
} from "@/composer/tracks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { AgentHeartbeatRow } from "./select";
|
||||
import type { AgentHeartbeatRow, AgentHeartbeatsTrack } from "./select";
|
||||
|
||||
const ThemedHeartPulse = withUnistyles(HeartPulse);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
@@ -35,21 +35,49 @@ function renderHeartbeatIcon(): ReactElement {
|
||||
}
|
||||
|
||||
export interface HeartbeatsTrackProps {
|
||||
/** Non-empty. The composer decides which tracks exist; a track always has rows. */
|
||||
rows: AgentHeartbeatRow[];
|
||||
/** 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. Each row names the heartbeat and how
|
||||
* often it fires; opening one goes to it on Schedules, deleting one stops it.
|
||||
* 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,
|
||||
}: HeartbeatsTrackProps): ReactElement {
|
||||
}: {
|
||||
rows: AgentHeartbeatRow[];
|
||||
onOpenHeartbeat: (row: AgentHeartbeatRow) => void;
|
||||
onDeleteHeartbeat: (row: AgentHeartbeatRow) => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const label =
|
||||
rows.length === 1
|
||||
@@ -115,3 +143,24 @@ function HeartbeatsTrackRow({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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],
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSchedules } from "@/hooks/use-schedules";
|
||||
import type { AggregatedSchedule } from "@/schedules/aggregated-schedules";
|
||||
import { selectAgentHeartbeats, type AgentHeartbeatRow } from "./select";
|
||||
|
||||
const EMPTY_SCHEDULES: AggregatedSchedule[] = [];
|
||||
|
||||
/**
|
||||
* The active heartbeats aimed at one agent, over the schedule list the
|
||||
* Schedules screen already loads. Direct deletes update that cache
|
||||
* optimistically; heartbeats created elsewhere appear on its next refresh.
|
||||
*/
|
||||
export function useAgentHeartbeats(target: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}): AgentHeartbeatRow[] {
|
||||
const { serverId, agentId } = target;
|
||||
const { loadState, dataUpdatedAt } = useSchedules();
|
||||
const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES;
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
selectAgentHeartbeats({
|
||||
schedules,
|
||||
target: { serverId, agentId },
|
||||
now: dataUpdatedAt || Date.now(),
|
||||
}),
|
||||
[schedules, serverId, agentId, dataUpdatedAt],
|
||||
);
|
||||
}
|
||||
@@ -1501,6 +1501,9 @@ export const ar: TranslationResources = {
|
||||
openAction: "{{title}}، {{cadence}}",
|
||||
deleteAction: "حذف النبضة {{title}}",
|
||||
deleteTooltip: "حذف النبضة",
|
||||
hostUpdate: {
|
||||
label: "قم بتحديث المضيف لعرض نشاط النبضات",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1512,6 +1512,9 @@ export const en = {
|
||||
openAction: "{{title}}, {{cadence}}",
|
||||
deleteAction: "Delete heartbeat {{title}}",
|
||||
deleteTooltip: "Delete heartbeat",
|
||||
hostUpdate: {
|
||||
label: "Update the host to show heartbeat activity",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1544,6 +1544,9 @@ export const es: TranslationResources = {
|
||||
openAction: "{{title}}, {{cadence}}",
|
||||
deleteAction: "Eliminar el latido {{title}}",
|
||||
deleteTooltip: "Eliminar latido",
|
||||
hostUpdate: {
|
||||
label: "Actualiza el host para mostrar la actividad de los latidos",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1548,6 +1548,9 @@ export const fr: TranslationResources = {
|
||||
openAction: "{{title}}, {{cadence}}",
|
||||
deleteAction: "Supprimer la pulsation {{title}}",
|
||||
deleteTooltip: "Supprimer la pulsation",
|
||||
hostUpdate: {
|
||||
label: "Mettez à jour l'hôte pour afficher l'activité des pulsations",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1517,6 +1517,9 @@ export const ja: TranslationResources = {
|
||||
openAction: "{{title}}、{{cadence}}",
|
||||
deleteAction: "ハートビート{{title}}を削除",
|
||||
deleteTooltip: "ハートビートを削除",
|
||||
hostUpdate: {
|
||||
label: "ハートビートのアクティビティを表示するにはホストを更新してください",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1530,6 +1530,9 @@ export const ptBR: TranslationResources = {
|
||||
openAction: "{{title}}, {{cadence}}",
|
||||
deleteAction: "Excluir o batimento {{title}}",
|
||||
deleteTooltip: "Excluir batimento",
|
||||
hostUpdate: {
|
||||
label: "Atualize o host para mostrar a atividade dos batimentos",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1535,6 +1535,9 @@ export const ru: TranslationResources = {
|
||||
openAction: "{{title}}, {{cadence}}",
|
||||
deleteAction: "Удалить пульс {{title}}",
|
||||
deleteTooltip: "Удалить пульс",
|
||||
hostUpdate: {
|
||||
label: "Обновите хост, чтобы показывать активность пульса",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1481,6 +1481,9 @@ export const zhCN: TranslationResources = {
|
||||
openAction: "{{title}},{{cadence}}",
|
||||
deleteAction: "删除心跳 {{title}}",
|
||||
deleteTooltip: "删除心跳",
|
||||
hostUpdate: {
|
||||
label: "更新 Host 以显示心跳活动",
|
||||
},
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -90,9 +90,9 @@ import {
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import { ComposerTracks } from "@/composer/tracks";
|
||||
import { HeartbeatsTrack } from "@/heartbeats/track";
|
||||
import { navigateToHeartbeat } from "@/heartbeats/navigate";
|
||||
import { navigateToHeartbeat, navigateToHostUpdate } from "@/heartbeats/navigate";
|
||||
import type { AgentHeartbeatRow } from "@/heartbeats/select";
|
||||
import { useAgentHeartbeats } from "@/heartbeats/use-agent-heartbeats";
|
||||
import { useAgentHeartbeatsTrack } from "@/heartbeats/use-agent-heartbeats-track";
|
||||
import { useDeleteHeartbeat } from "@/heartbeats/use-delete-heartbeat";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -1473,11 +1473,14 @@ function ActiveAgentComposer({
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const heartbeatRows = useAgentHeartbeats({ serverId, agentId });
|
||||
const heartbeatsTrack = useAgentHeartbeatsTrack({ serverId, agentId });
|
||||
const handleDeleteHeartbeat = useDeleteHeartbeat({ serverId });
|
||||
const handleOpenHeartbeat = useCallback((row: AgentHeartbeatRow) => {
|
||||
navigateToHeartbeat({ serverId: row.serverId, scheduleId: row.scheduleId });
|
||||
}, []);
|
||||
const handleUpdateHost = useCallback(() => {
|
||||
navigateToHostUpdate(serverId);
|
||||
}, [serverId]);
|
||||
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
@@ -1569,14 +1572,15 @@ function ActiveAgentComposer({
|
||||
return (
|
||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<ComposerTracks>
|
||||
{heartbeatRows.length > 0 ? (
|
||||
{heartbeatsTrack.kind === "none" ? null : (
|
||||
<HeartbeatsTrack
|
||||
key="heartbeats"
|
||||
rows={heartbeatRows}
|
||||
track={heartbeatsTrack}
|
||||
onOpenHeartbeat={handleOpenHeartbeat}
|
||||
onDeleteHeartbeat={handleDeleteHeartbeat}
|
||||
onUpdateHost={handleUpdateHost}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
{subagentRows.length > 0 ? (
|
||||
<SubagentsTrack
|
||||
key="subagents"
|
||||
|
||||
@@ -51,6 +51,7 @@ export const GitPullRequestClosed = StubIcon;
|
||||
export const GitPullRequestDraft = StubIcon;
|
||||
export const Globe = StubIcon;
|
||||
export const Heart = StubIcon;
|
||||
export const HeartPulse = StubIcon;
|
||||
export const History = StubIcon;
|
||||
export const Home = StubIcon;
|
||||
export const Image = StubIcon;
|
||||
|
||||
@@ -307,6 +307,22 @@ describe("agent detach RPC", () => {
|
||||
expect(parsed.features?.agentDetach).toBe(true);
|
||||
});
|
||||
|
||||
test("parses the heartbeat workspace activity feature gate as optional", () => {
|
||||
const capable = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
serverId: "srv-capable",
|
||||
features: { heartbeatWorkspaceActivity: true },
|
||||
});
|
||||
const legacy = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
serverId: "srv-legacy",
|
||||
features: {},
|
||||
});
|
||||
|
||||
expect(capable?.features?.heartbeatWorkspaceActivity).toBe(true);
|
||||
expect(legacy?.features?.heartbeatWorkspaceActivity).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses the workspace-targeted session import feature gate", () => {
|
||||
const parsed = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
|
||||
@@ -2919,6 +2919,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceScriptManagement: z.boolean().optional(),
|
||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||
projectCustomIcon: z.boolean().optional(),
|
||||
// COMPAT(heartbeatWorkspaceActivity): added in v0.2.6, remove after 2027-02-02 once daemon floor >= v0.2.6.
|
||||
heartbeatWorkspaceActivity: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -935,6 +935,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
|
||||
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
|
||||
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
|
||||
expect(serverInfo.features?.heartbeatWorkspaceActivity).toBe(true);
|
||||
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
@@ -1601,6 +1601,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceScriptManagement: true,
|
||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||
projectCustomIcon: true,
|
||||
// COMPAT(heartbeatWorkspaceActivity): added in v0.2.6, remove after 2027-02-02 once daemon floor >= v0.2.6.
|
||||
heartbeatWorkspaceActivity: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user