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, }, }; }