diff --git a/backend/src/agent/podman.ts b/backend/src/agent/podman.ts index a3611fc..6099780 100644 --- a/backend/src/agent/podman.ts +++ b/backend/src/agent/podman.ts @@ -71,15 +71,17 @@ export class PodMan { await recordCollision(collision); const action = preferredAction(collision, prior); - const names = collision.engineers.join(' and '); + const names = collision.engineers.join(' + '); + const shortFile = collision.file.split('/').pop() ?? collision.file; + + // Terse, demo-centered alert — short and direct, not chatty AI prose. const message = - `${names} are both editing ${collision.file}` + - (collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') + - (prior?.priorOutcome?.accepted - ? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.` - : prior - ? ` I've seen this conflict pattern before.` - : ''); + `Conflict: ${names} both on ${shortFile}` + + (collision.githubState?.unpushed ? ' (unpushed).' : '.') + + (prior ? ' Seen before.' : ''); + + // Spoken line is even shorter so the voice cue lands fast on stage. + const voiceLine = `Conflict. ${names}, both on ${shortFile}.`; const intervention: Intervention = { id: `int_${Date.now()}`, @@ -106,6 +108,6 @@ export class PodMan { topic: DATA_TOPIC, }); await publishHermesMessage(this.room, collision, intervention); - if (collision.severity === 'critical') await speak(this.room, message); + if (collision.severity === 'critical') await speak(this.room, voiceLine); } } diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index ebcb90f..9f9a11b 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -105,6 +105,7 @@ export function PodView({ const [sharing, setSharing] = useState(false); const [playingBeat, setPlayingBeat] = useState(false); const [note, setNote] = useState(null); + const [audioBlocked, setAudioBlocked] = useState(false); const [leftStreamOpen, setLeftStreamOpen] = useState(() => readStoredBool('podman.myStreamOpen', true), ); @@ -132,6 +133,11 @@ export function PodView({ }; const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove()); const onDisconnected = () => onLeaveRef.current(); + // Browsers block autoplay of incoming audio until a user gesture unlocks it. + // Surface a button whenever the room can't play sound so PodMan's voice cues + // are actually heard. + const onPlaybackChanged = () => setAudioBlocked(!room.canPlaybackAudio); + onPlaybackChanged(); room .on(RoomEvent.ParticipantConnected, refresh) @@ -139,6 +145,7 @@ export function PodView({ .on(RoomEvent.ActiveSpeakersChanged, refresh) .on(RoomEvent.TrackSubscribed, onAudio) .on(RoomEvent.TrackUnsubscribed, onAudioGone) + .on(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged) .on(RoomEvent.Disconnected, onDisconnected); return () => { @@ -148,10 +155,21 @@ export function PodView({ .off(RoomEvent.ActiveSpeakersChanged, refresh) .off(RoomEvent.TrackSubscribed, onAudio) .off(RoomEvent.TrackUnsubscribed, onAudioGone) + .off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged) .off(RoomEvent.Disconnected, onDisconnected); }; }, [room, me]); + async function enableSound() { + if (!room) return; + try { + await room.startAudio(); + setAudioBlocked(!room.canPlaybackAudio); + } catch (e) { + setNote(`Could not enable sound: ${(e as Error).message}`); + } + } + useEffect(() => { return () => { beatRef.current?.stop(); @@ -340,6 +358,23 @@ export function PodView({ )} + {room && audioBlocked && ( + +
+ +
+ Sound is off + + Click to hear PodMan's voice alerts. + +
+
+ +
+ )} +