Skip to content

🤖 feat: add per-workspace heartbeat messages#3105

Merged
ThomasK33 merged 9 commits intomainfrom
heartbeat-9cdw
Apr 1, 2026
Merged

🤖 feat: add per-workspace heartbeat messages#3105
ThomasK33 merged 9 commits intomainfrom
heartbeat-9cdw

Conversation

@ThomasK33
Copy link
Copy Markdown
Member

Summary
Add a per-workspace heartbeat message override to the existing Configure heartbeat modal and use it when backend heartbeats run.

Background
Workspace heartbeats were already persisted per workspace, but they always used a fixed default prompt body. This change keeps the existing Workspace Heartbeats experiment gate while letting each workspace optionally customize the heartbeat instruction body without affecting other workspaces.

Implementation

  • extended the workspace heartbeat schema/router payload with an optional message
  • normalized whitespace-only values to clear the override and fall back to the default body
  • updated WorkspaceHeartbeatModal and useWorkspaceHeartbeat to edit, restore, and clear the workspace-scoped message
  • composed heartbeat prompts from a fixed idle-duration lead-in plus either the saved custom body or the default body
  • preserved stored heartbeat messages when /heartbeat commands only change cadence or disable the feature
  • added focused modal, slash-command, and backend heartbeat tests

Validation

  • make static-check
  • make test
  • bun test src/node/services/heartbeatService.test.ts src/browser/utils/chatCommands.test.ts src/browser/components/WorkspaceHeartbeatModal/WorkspaceHeartbeatModal.test.tsx
  • Dogfooded the modal flow in make dev-server-sandbox, including persistence across reopen and no cross-workspace bleed; captured screenshots and a local WebM recording during the run

Risks
The main regression risk is compatibility between the modal flow, backend heartbeat execution, and slash-command writes. The change stays within the existing workspace heartbeat model and adds tests for fallback/custom prompt composition plus message preservation across /heartbeat updates.


📋 Implementation Plan

Plan: per-workspace configurable heartbeat message

Goal

Add a per-workspace configurable heartbeat message through the existing Configure heartbeat modal, while keeping the existing Workspace Heartbeats experiment as the feature gate only.

Recommended approach

Option A (selected): extend the existing workspace-scoped heartbeat flownet +70 to +110 product LoC.

  • Keep Settings -> Experiments -> Workspace Heartbeats as the on/off gate.
  • Do not add a global textbox in ExperimentsSection; that screen is app-scoped and intentionally lacks selected-workspace context.
  • Add an optional per-workspace heartbeat message field to the existing heartbeat settings model and modal.
  • Treat the configured value as the instruction body for the heartbeat, not a raw full prompt template:
    • keep the fixed [Heartbeat] prefix
    • keep the computed idle-duration sentence
    • append either the custom message body or the existing default instruction body
  • Blank/whitespace input clears the override and falls back to the built-in default message.

Scope decisions

  • In scope: per-workspace persistence, workspace modal UI, backend prompt selection, compatibility updates for existing /heartbeat command paths.
  • Out of scope: global experiment-level textbox, placeholder/template syntax such as {idleDuration}, scheduling changes, new docs pages.
Why this matches current repo patterns
  • src/common/orpc/schemas/workspace.ts already stores heartbeat settings as workspace metadata.
  • src/browser/components/WorkspaceHeartbeatModal/WorkspaceHeartbeatModal.tsx is the existing per-workspace edit surface.
  • src/browser/features/Settings/Sections/ExperimentsSection.tsx supports nested controls for app-scoped experiments (for example, configurable bind URL), but that pattern is a poor fit for workspace-specific data because the settings route does not carry selected-workspace context.
  • src/node/services/workspaceService.ts#executeHeartbeat() currently builds the heartbeat prompt in one place, so prompt selection can stay centralized.

Implementation plan

Phase 1 — Extend the heartbeat settings shape and persistence

Files / symbols

  • src/common/orpc/schemas/workspace.ts
    • WorkspaceHeartbeatSettingsSchema
  • src/common/orpc/schemas/api.ts
    • workspace.heartbeat.get
    • workspace.heartbeat.set
  • src/node/orpc/router.ts
    • workspace.heartbeat.set
  • src/node/services/workspaceService.ts
    • setHeartbeatSettings()
    • getHeartbeatSettings()

Changes

  • Add an optional message field to WorkspaceHeartbeatSettingsSchema with a conservative max length (recommend 1000 chars for v1).
  • Thread that field through the heartbeat ORPC schema and router so input.message reaches setHeartbeatSettings().
  • In setHeartbeatSettings():
    • assert that message is either absent or a string
    • trim it
    • normalize empty/whitespace-only values to undefined
    • include message in the persisted object and in change detection
  • Preserve current backward compatibility: old workspaces without message continue to read as default/fallback behavior.

Defensive-programming notes

  • Keep explicit assertions next to the existing enabled / intervalMs assertions.
  • Prefer a normalized local variable before constructing nextSettings so changed detection and persistence compare the same shape.

Quality gate

  • make typecheck
  • targeted heartbeat schema/API tests if needed after the shape change lands

Phase 2 — Add the per-workspace message field to the existing modal

Files / symbols

  • src/browser/components/WorkspaceHeartbeatModal/WorkspaceHeartbeatModal.tsx
  • src/browser/hooks/useWorkspaceHeartbeat.ts

Changes

  • Extend the modal draft state with draftMessage.
  • Re-sync the draft message on open/workspace-switch alongside enabled and intervalMs.
  • Add a simple styled <textarea> to the modal instead of introducing a new shared component; keep the diff small.
  • Show helper text such as: “Leave empty to use the default heartbeat message.”
  • Use the current default instruction body as placeholder/help copy rather than pre-populating the field.
  • Preserve the saved custom message even if the user temporarily disables heartbeats.
  • Pass message through save(); send undefined when the trimmed draft is empty so clearing the field removes the override.

UI behavior choice

  • Keep the existing heartbeat modal as the single edit surface for workspace-scoped settings.
  • Show the new message field only when heartbeat is enabled in the modal, to mirror the user’s requested “toggle on -> reveal configurable value” interaction, but do not clear the persisted value when toggled off.

Quality gate

  • local UI validation for required trim/max-length behavior
  • component test for save + re-open behavior before moving on

Phase 3 — Centralize prompt composition with fallback behavior

Files / symbols

  • src/node/services/workspaceService.ts
    • executeHeartbeat()

Changes

  • Fetch the saved heartbeat settings inside executeHeartbeat() using the existing workspace-scoped settings path.
  • Split the prompt into:
    1. a fixed preamble with [Heartbeat] and the computed idle duration
    2. an instruction body that comes from settings.message when present, otherwise the current built-in default body
  • Keep muxMetadata.displayStatus, synthetic: true, and requireIdle: true unchanged.

Target shape

const heartbeatLead = `[Heartbeat] This workspace has been idle for approximately ${idleDuration}.`;
const heartbeatBody = customMessage ?? DEFAULT_HEARTBEAT_BODY;
const heartbeatPrompt = `${heartbeatLead} ${heartbeatBody}`;

Why this shape

  • preserves useful runtime context without introducing templating syntax
  • avoids requiring the user to remember/include idleDuration
  • keeps the fallback message unchanged for all existing workspaces

Quality gate

  • targeted backend test proving both the custom-message and fallback paths

Phase 4 — Preserve custom message across secondary write paths

Files / symbols

  • src/browser/utils/chatCommands.ts
    • processSlashCommand() heartbeat-set branch
  • src/browser/utils/chatCommands.test.ts

Changes

  • Update the /heartbeat command path so changing cadence or toggling heartbeats off does not accidentally clear the saved custom message.
  • Preserve the stored message the same way the current code already preserves the stored interval when disabling heartbeats.
  • Keep the API write semantics explicit at the callsite rather than relying on hidden merge behavior.

Why this matters

  • today workspace.heartbeat.set() is called from both the modal hook and the slash-command path
  • without a compatibility update, /heartbeat 30 or /heartbeat off would overwrite the saved heartbeat message

Quality gate

  • targeted slash-command tests for both enable and disable flows

Test plan

Update existing tests

  • src/node/services/heartbeatService.test.ts
    • extend the existing prompt assertions to cover:
      • custom message body is used when stored
      • fallback default body is used when no custom message exists
  • src/browser/utils/chatCommands.test.ts
    • assert /heartbeat off preserves the stored message
    • assert /heartbeat <minutes> preserves the stored message when only cadence changes

Add focused UI coverage

  • Add src/browser/components/WorkspaceHeartbeatModal/WorkspaceHeartbeatModal.test.tsx
    • renders the new message field when enabled
    • saves a custom message
    • reopens with the saved message restored
    • clearing the field removes the override instead of persisting whitespace

Validation commands

  • make typecheck
  • make lint
  • targeted bun test for the touched suites above
  • make test before claiming completion if the targeted runs stay green and runtime budget allows

Acceptance criteria

  • The heartbeat modal exposes a per-workspace message field behind the existing workspace heartbeat flow.
  • Saving the modal persists message alongside enabled and intervalMs for that workspace only.
  • Reopening the modal for the same workspace restores the saved message; a different workspace does not inherit it.
  • Clearing the message field removes the override and reverts to the built-in default heartbeat body.
  • Heartbeat execution uses the custom message body when present and the current default body otherwise.
  • Existing /heartbeat commands do not wipe the saved custom message.
  • No new global experiment textbox is added.

Dogfooding and review artifacts

Setup

  • Start an isolated sandbox with make dev-server-sandbox.
  • Use the sandbox's printed Vite URL rather than your normal local mux instance so the dogfood run stays isolated.
  • Drive the sandboxed UI with agent-browser against that URL and capture screenshots/video from that session.
  • If sandbox seeding would make the test noisy, use DEV_SERVER_SANDBOX_ARGS="--clean-projects" (and --clean-providers too if you want a fully blank environment).

Manual dogfood flow

  1. Enable the Workspace Heartbeats experiment if it is not already on.
  2. Open a real workspace.
  3. Open Configure heartbeat from the workspace UI.
  4. Enable heartbeats and enter:
    • a valid interval
    • a custom heartbeat message
  5. Save, reopen the modal, and confirm the message persists.
  6. Switch to a second workspace and confirm it still shows the default/empty message state.
  7. Clear the custom message, save again, reopen, and confirm fallback behavior.

Artifact requirements

  • Capture at least 3 screenshots:
    1. the modal showing the new message field
    2. the modal reopened with the persisted custom message
    3. a second workspace showing no cross-workspace bleed
  • Capture one short video showing: open modal -> enter message -> save -> reopen -> clear -> save again.
  • If practical during the same session, capture one extra screenshot of a heartbeat transcript/log path using the custom body; otherwise rely on the targeted backend test for prompt verification and note that the live scheduler minimum makes full end-to-end timing slower.

Risks / watchpoints

  • The router currently forwards only enabled and intervalMs; forgetting to thread message there would silently drop the new field.
  • chatCommands.ts is a compatibility trap because it already writes heartbeat settings outside the modal.
  • Avoid inventing placeholder/template syntax in v1; it adds UX and validation surface area without being necessary for this request.

Handoff note

If implementation starts later in Exec mode, keep the diff focused on the files above and avoid adding a second settings surface unless product requirements explicitly change toward a global default.


Generated with mux • Model: openai:gpt-5.4 • Thinking: xhigh • Cost: $18.31

Add a workspace-scoped heartbeat message override to the existing heartbeat
modal and preserve it across backend prompt composition and slash-command
updates.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Trigger a fresh PR check run for the per-workspace heartbeat message change.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51a4524150

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Make /heartbeat writes treat reading existing heartbeat settings as best-effort so malformed persisted data does not block interval updates that can repair state.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Please take another look.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a038e03162

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Make heartbeat interval updates omit the message field on read failure so the backend can preserve any existing custom message, and add focused tests for the non-destructive server-side merge behavior.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Please take another look.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32772b9c75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Local static-check passes, so trigger a fresh CI run for the heartbeat PR after the transient static-check failure.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Please take another look.

Avoid materializing an undefined message field in the heartbeat ORPC router so omitted message updates keep the server-side preservation behavior intact.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Please take another look.

Local make static-check-full passes, so trigger a fresh CI run after the transient Static Checks failure.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Please take another look.

Clean up the new heartbeat settings test so CI static checks match the local static-check-full run.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Please take another look.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de26abd4c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Return the saved heartbeat message alongside the resolved send options so executeHeartbeat avoids redundant config reloads while still honoring the per-workspace override.

---

_Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$18.31`_

<!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=18.31 -->
@ThomasK33
Copy link
Copy Markdown
Member Author

@codex review

Please take another look.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33 ThomasK33 added this pull request to the merge queue Apr 1, 2026
Merged via the queue into main with commit 8d778e7 Apr 1, 2026
24 checks passed
@ThomasK33 ThomasK33 deleted the heartbeat-9cdw branch April 1, 2026 06:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant