From c73b03a168ab865af5010f0567ff4723c7749c8e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 2 Aug 2026 21:31:19 +0000 Subject: [PATCH] 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. --- docs/glossary.md | 2 +- packages/app/src/heartbeats/navigate.ts | 10 +- packages/app/src/heartbeats/select.test.ts | 35 +++++- packages/app/src/heartbeats/select.ts | 35 ++++++ packages/app/src/heartbeats/track.test.tsx | 118 ++++++++++++++++++ packages/app/src/heartbeats/track.tsx | 61 ++++++++- .../heartbeats/use-agent-heartbeats-track.ts | 39 ++++++ .../src/heartbeats/use-agent-heartbeats.ts | 30 ----- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/app/src/panels/agent-panel.tsx | 16 ++- .../app/test-stubs/lucide-react-native.ts | 1 + packages/protocol/src/messages.test.ts | 16 +++ packages/protocol/src/messages.ts | 2 + .../websocket-server.relay-reconnect.test.ts | 1 + .../server/src/server/websocket-server.ts | 2 + 22 files changed, 347 insertions(+), 45 deletions(-) create mode 100644 packages/app/src/heartbeats/track.test.tsx create mode 100644 packages/app/src/heartbeats/use-agent-heartbeats-track.ts delete mode 100644 packages/app/src/heartbeats/use-agent-heartbeats.ts diff --git a/docs/glossary.md b/docs/glossary.md index 6a408f9643..f0f3d6950b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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. diff --git a/packages/app/src/heartbeats/navigate.ts b/packages/app/src/heartbeats/navigate.ts index 732e1a1b6e..584c815fe8 100644 --- a/packages/app/src/heartbeats/navigate.ts +++ b/packages/app/src/heartbeats/navigate.ts @@ -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); +} diff --git a/packages/app/src/heartbeats/select.test.ts b/packages/app/src/heartbeats/select.test.ts index a97205d8c1..6f7e20bc5e 100644 --- a/packages/app/src/heartbeats/select.test.ts +++ b/packages/app/src/heartbeats/select.test.ts @@ -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" }); + }); +}); diff --git a/packages/app/src/heartbeats/select.ts b/packages/app/src/heartbeats/select.ts index 8cb9c8f595..0b96d334be 100644 --- a/packages/app/src/heartbeats/select.ts +++ b/packages/app/src/heartbeats/select.ts @@ -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; +} diff --git a/packages/app/src/heartbeats/track.test.tsx b/packages/app/src/heartbeats/track.test.tsx new file mode 100644 index 0000000000..d418fdae65 --- /dev/null +++ b/packages/app/src/heartbeats/track.test.tsx @@ -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>) => + ({ + uniProps, + ...rest + }: { uniProps?: (theme: unknown) => Record } & Record) => + React.createElement(Component, { ...rest, ...(uniProps ? uniProps(theme) : {}) }), +})); + +vi.stubGlobal("React", React); + +function makeRow(overrides: Partial = {}): 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["track"]) { + const handlers = { + onOpenHeartbeat: vi.fn(), + onDeleteHeartbeat: vi.fn(), + onUpdateHost: vi.fn(), + }; + render(); + 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(); + }); +}); diff --git a/packages/app/src/heartbeats/track.tsx b/packages/app/src/heartbeats/track.tsx index a69fe19c97..5e79aecd90 100644 --- a/packages/app/src/heartbeats/track.tsx +++ b/packages/app/src/heartbeats/track.tsx @@ -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; 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 ; + } + + return ( + + ); +} + +/** + * 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 ( + + ); +} diff --git a/packages/app/src/heartbeats/use-agent-heartbeats-track.ts b/packages/app/src/heartbeats/use-agent-heartbeats-track.ts new file mode 100644 index 0000000000..e781cb9a20 --- /dev/null +++ b/packages/app/src/heartbeats/use-agent-heartbeats-track.ts @@ -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], + ); +} diff --git a/packages/app/src/heartbeats/use-agent-heartbeats.ts b/packages/app/src/heartbeats/use-agent-heartbeats.ts deleted file mode 100644 index 9c1feb4c79..0000000000 --- a/packages/app/src/heartbeats/use-agent-heartbeats.ts +++ /dev/null @@ -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], - ); -} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b6b2a5078d..f46a898f02 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1501,6 +1501,9 @@ export const ar: TranslationResources = { openAction: "{{title}}، {{cadence}}", deleteAction: "حذف النبضة {{title}}", deleteTooltip: "حذف النبضة", + hostUpdate: { + label: "قم بتحديث المضيف لعرض نشاط النبضات", + }, }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index fa551a1aa7..0e20b1881b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 229e00e347..377eb88d1b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 25d5199e9f..baf5c50f38 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ad1a76eecc..d79852f5c3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1517,6 +1517,9 @@ export const ja: TranslationResources = { openAction: "{{title}}、{{cadence}}", deleteAction: "ハートビート{{title}}を削除", deleteTooltip: "ハートビートを削除", + hostUpdate: { + label: "ハートビートのアクティビティを表示するにはホストを更新してください", + }, }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 80eabc630e..65fd64d2e4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 193ac62f75..ff6a53b099 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1535,6 +1535,9 @@ export const ru: TranslationResources = { openAction: "{{title}}, {{cadence}}", deleteAction: "Удалить пульс {{title}}", deleteTooltip: "Удалить пульс", + hostUpdate: { + label: "Обновите хост, чтобы показывать активность пульса", + }, }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 80277146d8..cbf07d6179 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1481,6 +1481,9 @@ export const zhCN: TranslationResources = { openAction: "{{title}},{{cadence}}", deleteAction: "删除心跳 {{title}}", deleteTooltip: "删除心跳", + hostUpdate: { + label: "更新 Host 以显示心跳活动", + }, }, panels: { draft: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 761c8eb757..c826722bdd 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ( - {heartbeatRows.length > 0 ? ( + {heartbeatsTrack.kind === "none" ? null : ( - ) : null} + )} {subagentRows.length > 0 ? ( { 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", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index be75792574..2473b12f2f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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(), }) diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 800fa3c8c6..f168e50603 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -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(); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index fa70b99058..c39ff34dbc 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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, }, }; }