@@ -10,7 +10,9 @@ import {
1010import { Input } from "@/browser/components/Input/Input" ;
1111import { Switch } from "@/browser/components/Switch/Switch" ;
1212import { usePersistedState } from "@/browser/hooks/usePersistedState" ;
13+ import { useExperimentValue } from "@/browser/hooks/useExperiments" ;
1314import { useAPI } from "@/browser/contexts/API" ;
15+ import { EXPERIMENT_IDS } from "@/common/constants/experiments" ;
1416import { CUSTOM_EVENTS , createCustomEvent } from "@/common/constants/events" ;
1517import {
1618 EDITOR_CONFIG_KEY ,
@@ -39,6 +41,7 @@ import {
3941 isWorktreeArchiveBehavior ,
4042 type WorktreeArchiveBehavior ,
4143} from "@/common/config/worktreeArchiveBehavior" ;
44+ import { HEARTBEAT_DEFAULT_MESSAGE_BODY } from "@/constants/heartbeat" ;
4245
4346// Guard against corrupted/old persisted settings (e.g. from a downgraded build).
4447const ALLOWED_EDITOR_TYPES : ReadonlySet < EditorType > = new Set ( [
@@ -160,6 +163,7 @@ const isBrowserMode = typeof window !== "undefined" && !window.api;
160163export function GeneralSection ( ) {
161164 const { themePreference, setTheme } = useTheme ( ) ;
162165 const { api } = useAPI ( ) ;
166+ const workspaceHeartbeatsEnabled = useExperimentValue ( EXPERIMENT_IDS . WORKSPACE_HEARTBEATS ) ;
163167 const [ launchBehavior , setLaunchBehavior ] = usePersistedState < LaunchBehavior > (
164168 LAUNCH_BEHAVIOR_KEY ,
165169 "dashboard"
@@ -203,18 +207,23 @@ export function GeneralSection() {
203207 ) ;
204208 const [ archiveSettingsLoaded , setArchiveSettingsLoaded ] = useState ( false ) ;
205209 const [ llmDebugLogs , setLlmDebugLogs ] = useState ( false ) ;
210+ const [ heartbeatDefaultPrompt , setHeartbeatDefaultPrompt ] = useState ( "" ) ;
211+ const [ heartbeatDefaultPromptLoaded , setHeartbeatDefaultPromptLoaded ] = useState ( false ) ;
212+ const [ heartbeatDefaultPromptLoadedOk , setHeartbeatDefaultPromptLoadedOk ] = useState ( false ) ;
206213 const archiveBehaviorLoadNonceRef = useRef ( 0 ) ;
207214 const archiveBehaviorRef = useRef < CoderWorkspaceArchiveBehavior > ( DEFAULT_CODER_ARCHIVE_BEHAVIOR ) ;
208215 const worktreeArchiveBehaviorRef = useRef < WorktreeArchiveBehavior > (
209216 DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR
210217 ) ;
211218
212219 const llmDebugLogsLoadNonceRef = useRef ( 0 ) ;
220+ const heartbeatDefaultPromptLoadNonceRef = useRef ( 0 ) ;
213221
214222 // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid
215223 // selections can't race and persist a stale value via out-of-order writes.
216224 const archiveBehaviorUpdateChainRef = useRef < Promise < void > > ( Promise . resolve ( ) ) ;
217225 const llmDebugLogsUpdateChainRef = useRef < Promise < void > > ( Promise . resolve ( ) ) ;
226+ const heartbeatDefaultPromptUpdateChainRef = useRef < Promise < void > > ( Promise . resolve ( ) ) ;
218227 const archiveBehaviorPendingUpdateRef = useRef < CoderWorkspaceArchiveBehavior | undefined > (
219228 undefined
220229 ) ;
@@ -228,8 +237,11 @@ export function GeneralSection() {
228237 }
229238
230239 setArchiveSettingsLoaded ( false ) ;
240+ setHeartbeatDefaultPromptLoaded ( false ) ;
241+ setHeartbeatDefaultPromptLoadedOk ( false ) ;
231242 const archiveBehaviorNonce = ++ archiveBehaviorLoadNonceRef . current ;
232243 const llmDebugLogsNonce = ++ llmDebugLogsLoadNonceRef . current ;
244+ const heartbeatDefaultPromptNonce = ++ heartbeatDefaultPromptLoadNonceRef . current ;
233245
234246 void api . config
235247 . getConfig ( )
@@ -256,13 +268,25 @@ export function GeneralSection() {
256268 if ( llmDebugLogsNonce === llmDebugLogsLoadNonceRef . current ) {
257269 setLlmDebugLogs ( cfg . llmDebugLogs === true ) ;
258270 }
271+
272+ if ( heartbeatDefaultPromptNonce === heartbeatDefaultPromptLoadNonceRef . current ) {
273+ setHeartbeatDefaultPrompt ( cfg . heartbeatDefaultPrompt ?? "" ) ;
274+ setHeartbeatDefaultPromptLoaded ( true ) ;
275+ setHeartbeatDefaultPromptLoadedOk ( true ) ;
276+ }
259277 } )
260278 . catch ( ( ) => {
261279 if ( archiveBehaviorNonce === archiveBehaviorLoadNonceRef . current ) {
262280 // Fall back to the safe defaults already in state so the controls can recover after a
263281 // config read failure and the next user change can persist a fresh value.
264282 setArchiveSettingsLoaded ( true ) ;
265283 }
284+
285+ if ( heartbeatDefaultPromptNonce === heartbeatDefaultPromptLoadNonceRef . current ) {
286+ // Keep the field editable after load failures, but avoid clearing an existing saved
287+ // prompt unless the user has actively typed a replacement in this session.
288+ setHeartbeatDefaultPromptLoaded ( true ) ;
289+ }
266290 } ) ;
267291 } , [ api ] ) ;
268292
@@ -367,6 +391,35 @@ export function GeneralSection() {
367391 } ) ;
368392 } ;
369393
394+ const handleHeartbeatDefaultPromptBlur = useCallback ( ( ) => {
395+ if ( ! heartbeatDefaultPromptLoaded || ! api ?. config ?. updateHeartbeatDefaultPrompt ) {
396+ return ;
397+ }
398+
399+ const trimmedDefaultPrompt = heartbeatDefaultPrompt . trim ( ) ;
400+ if ( ! heartbeatDefaultPromptLoadedOk && ! trimmedDefaultPrompt ) {
401+ return ;
402+ }
403+
404+ setHeartbeatDefaultPrompt ( trimmedDefaultPrompt ) ;
405+
406+ heartbeatDefaultPromptUpdateChainRef . current = heartbeatDefaultPromptUpdateChainRef . current
407+ . catch ( ( ) => {
408+ // Best-effort only.
409+ } )
410+ . then ( ( ) =>
411+ api . config . updateHeartbeatDefaultPrompt ( {
412+ defaultPrompt : trimmedDefaultPrompt || null ,
413+ } )
414+ )
415+ . then ( ( ) => {
416+ setHeartbeatDefaultPromptLoadedOk ( true ) ;
417+ } )
418+ . catch ( ( ) => {
419+ // Best-effort persistence.
420+ } ) ;
421+ } , [ api , heartbeatDefaultPrompt , heartbeatDefaultPromptLoaded , heartbeatDefaultPromptLoadedOk ] ) ;
422+
370423 // Load SSH host from server on mount (browser mode only)
371424 useEffect ( ( ) => {
372425 if ( isBrowserMode && api ) {
@@ -559,6 +612,30 @@ export function GeneralSection() {
559612 aria-label = "Toggle API Debug Logs"
560613 />
561614 </ div >
615+ { workspaceHeartbeatsEnabled ? (
616+ < div className = "py-3" >
617+ < label htmlFor = "heartbeat-default-prompt" className = "block" >
618+ < div className = "text-foreground text-sm" > Default heartbeat prompt</ div >
619+ < div className = "text-muted mt-0.5 text-xs" >
620+ Used for workspace heartbeats when a workspace does not set its own message.
621+ </ div >
622+ </ label >
623+ < textarea
624+ id = "heartbeat-default-prompt"
625+ rows = { 4 }
626+ value = { heartbeatDefaultPrompt }
627+ onChange = { ( event : React . ChangeEvent < HTMLTextAreaElement > ) => {
628+ heartbeatDefaultPromptLoadNonceRef . current ++ ;
629+ setHeartbeatDefaultPromptLoaded ( true ) ;
630+ setHeartbeatDefaultPrompt ( event . target . value ) ;
631+ } }
632+ onBlur = { handleHeartbeatDefaultPromptBlur }
633+ className = "border-border-medium bg-background-secondary text-foreground focus:border-accent focus:ring-accent mt-3 min-h-[120px] w-full resize-y rounded-md border p-3 text-sm leading-relaxed focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
634+ placeholder = { HEARTBEAT_DEFAULT_MESSAGE_BODY }
635+ aria-label = "Default heartbeat prompt"
636+ />
637+ </ div >
638+ ) : null }
562639 </ div >
563640 </ div >
564641
0 commit comments