11/**
22 * The banner above a wake narration.
33 *
4- * A wake arrives unprompted: nobody typed anything, the watch fired and the chat
5- * spoke. Rendered as plain assistant prose that reads like an answer to a
6- * question the user never asked — so the narration gets a banner that says what
7- * happened before the text does, with the outcome carried by a coloured accent
8- * and icon (the same rule the run status cells and the watch chips follow: the
9- * text keeps its colour, the state is the icon's job).
4+ * A wake arrives unprompted: nobody typed anything, the watch resolved and the
5+ * chat spoke. Rendered as plain assistant prose it would read like an answer to a
6+ * question the user never asked — so the narration gets a banner that states the
7+ * FACT before the text does, with the outcome carried by a coloured accent and
8+ * icon (the same rule the run status cells and the watch chips follow: the text
9+ * keeps its colour, the state is the icon's job).
1010 *
11- * A wake is identified by its message id — `wake:watch:{watchId}:{fired|expired}`,
12- * written by the agent's `narrateWatchWake` — so no protocol change is needed to
13- * spot one in the transcript.
11+ * **This component contains no kind-specific wording.** Category, tone, semantic
12+ * icon and headline key come from the exhaustive resolved-result mapping in
13+ * contracts; the final English comes from `watch-presentation.ts`. All this file
14+ * decides is which glyph a semantic icon draws and which frame a tone paints.
15+ *
16+ * A wake is identified by its message id — `wake:watch:{watchId}:{fired|expired}`.
17+ * That two-value suffix is the stable TRANSPORT encoding (§7.5): it is not the
18+ * outcome, it is how the wake is addressed. The outcome comes off the watch row.
1419 */
15- import { CheckCircleIcon , ClockIcon , ExclamationTriangleIcon } from "@heroicons/react/20/solid" ;
20+ import {
21+ CheckCircleIcon ,
22+ ClockIcon ,
23+ ExclamationCircleIcon ,
24+ ExclamationTriangleIcon ,
25+ InformationCircleIcon ,
26+ } from "@heroicons/react/20/solid" ;
27+ import type {
28+ WatchObservedOutcome ,
29+ WatchResolution ,
30+ WatchSemanticIcon ,
31+ } from "@internal/dashboard-agent-contracts" ;
1632import { cn } from "~/utils/cn" ;
1733import { type AgentTone , TONE_ICON_COLOR } from "./agent-badges" ;
34+ import { presentResolvedWatch , WATCH_PRESENTATION_FALLBACK } from "./watch-presentation" ;
1835
1936const WAKE_ID_PREFIX = "wake:watch:" ;
2037
38+ /**
39+ * The wire encoding in a wake's message id. NOT the resolution — a
40+ * `window_completed` and a `condition_impossible` are both addressed as
41+ * `expired`, and the row is the authority on which one it was.
42+ */
2143export type WakeOutcome = "fired" | "expired" ;
2244
2345/** The watch fields a banner can use. A `WatchChip` satisfies it. */
@@ -26,21 +48,18 @@ export type WakeWatch = {
2648 kind : string ;
2749 note : string ;
2850 identity : string ;
51+ /** How the watch ended. Absent on a row written before the resolution model. */
52+ resolution ?: WatchResolution | null ;
53+ /** What the resolving check observed — the other half of the headline. */
54+ observedOutcome ?: WatchObservedOutcome | null ;
2955 /**
3056 * Why the watch ended, from its last result — `terminal_unsatisfied` when the
31- * condition became impossible (a cancelled run, a deleted queue). Absent when
32- * the host doesn't carry it, and the banner falls back to the plain expiry .
57+ * condition became impossible. Only used to reconstruct a resolution for rows
58+ * that predate the `resolution` column .
3359 */
3460 endedReason ?: string | null ;
3561} ;
3662
37- /**
38- * A watch that expired because its condition can never happen now. That IS an
39- * answer, so it gets its own headline instead of "no answer" — the watcher writes
40- * this reason when a check comes back `terminal_unsatisfied`.
41- */
42- const TERMINAL_UNSATISFIED = "terminal_unsatisfied" ;
43-
4463export type WakeRef = { watchId : string ; outcome : WakeOutcome } ;
4564
4665/**
@@ -63,44 +82,49 @@ export function findWakeWatch(watches: WakeWatch[] | undefined, watchId: string)
6382 return watches ?. find ( ( watch ) => watch . id === watchId ) ;
6483}
6584
66- // Kinds whose condition being met is good news. `error_recurrence` is the
67- // inverse: it fires because something broke again.
68- const GOOD_NEWS_KINDS = new Set ( [ "health_recovery" , "backlog_drain" , "run_start" , "run_finished" ] ) ;
69-
70- function toneFor ( outcome : WakeOutcome , kind : string | undefined ) : AgentTone {
71- if ( outcome === "expired" ) return "neutral" ;
72- if ( kind === "error_recurrence" ) return "error" ;
73- if ( kind && GOOD_NEWS_KINDS . has ( kind ) ) return "success" ;
74- // Fired, but we don't know what for: say so without claiming an outcome.
75- return "neutral" ;
85+ /**
86+ * The watch's resolution, falling back to what the transport can prove for a row
87+ * written before the `resolution` column existed. `fired` is unambiguous;
88+ * `expired` splits on the last check's reason, exactly as the old banner did.
89+ */
90+ export function wakeResolution (
91+ outcome : WakeOutcome ,
92+ watch : Pick < WakeWatch , "resolution" | "endedReason" > | undefined
93+ ) : WatchResolution {
94+ if ( watch ?. resolution ) return watch . resolution ;
95+ if ( outcome === "fired" ) return "condition_met" ;
96+ return watch ?. endedReason === "terminal_unsatisfied"
97+ ? "condition_impossible"
98+ : "window_completed" ;
7699}
77100
78- /** The banner's headline. Exported for the tests; the component owns the tone. */
79- export function wakeHeadline (
80- outcome : WakeOutcome ,
81- watch : Pick < WakeWatch , "endedReason" > | undefined ,
82- tone : AgentTone
83- ) : string {
84- if ( outcome === "expired" ) {
85- return watch ?. endedReason === TERMINAL_UNSATISFIED
86- ? "Watch ended — the condition can no longer happen"
87- : "Watch expired — no answer" ;
88- }
89- switch ( tone ) {
90- case "success" :
91- return "Watch update — all clear" ;
92- case "error" :
93- return "Watch update — needs your attention" ;
94- default :
95- return "Watch update — condition met" ;
96- }
101+ /**
102+ * What this banner shows. Exported for the tests and for any surface that wants
103+ * the same answer without the markup.
104+ */
105+ export function wakePresentation ( outcome : WakeOutcome , watch : WakeWatch | undefined ) {
106+ if ( ! watch ) return WATCH_PRESENTATION_FALLBACK ;
107+ return presentResolvedWatch ( {
108+ kind : watch . kind ,
109+ identity : watch . identity ,
110+ resolution : wakeResolution ( outcome , watch ) ,
111+ observed : watch . observedOutcome ?? null ,
112+ } ) ;
97113}
98114
99- const TONE_ICON : Record < AgentTone , ( props : { className ?: string } ) => JSX . Element > = {
100- neutral : ClockIcon ,
115+ /**
116+ * Semantic icon → glyph. The mapping lives here because the icon SET is this
117+ * app's; which icon a resolved result deserves was decided in contracts, and the
118+ * rule it encodes is that the icon follows the outcome, never the resolution — a
119+ * failed run gets `error`, not the success check its `condition_met` would
120+ * otherwise suggest.
121+ */
122+ const SEMANTIC_ICON : Record < WatchSemanticIcon , ( props : { className ?: string } ) => JSX . Element > = {
101123 success : CheckCircleIcon ,
102- warning : ExclamationTriangleIcon ,
103- error : ExclamationTriangleIcon ,
124+ attention : ExclamationTriangleIcon ,
125+ error : ExclamationCircleIcon ,
126+ waiting : ClockIcon ,
127+ info : InformationCircleIcon ,
104128} ;
105129
106130const TONE_FRAME : Record < AgentTone , string > = {
@@ -111,32 +135,39 @@ const TONE_FRAME: Record<AgentTone, string> = {
111135} ;
112136
113137/** What the watch was for: the user's own words, else whatever names it. */
114- function subline ( watch : WakeWatch | undefined ) : string {
138+ function subline ( watch : WakeWatch | undefined ) : string | null {
115139 const note = watch ?. note . trim ( ) ;
116140 if ( note ) return note ;
117141 if ( watch ?. identity ) return watch . identity ;
118142 if ( watch ?. kind ) return watch . kind ;
119- return "The watch woke this chat up on its own." ;
143+ return null ;
120144}
121145
122146export function WakeBanner ( {
123147 outcome,
124148 watch,
125149} : {
150+ /** The wire encoding from the wake's message id (§7.5). */
126151 outcome : WakeOutcome ;
127- /** The watch that woke, when the host has it. Absent: kind-agnostic wording . */
152+ /** The watch that woke, when the host has it. Absent: the neutral fallback . */
128153 watch ?: WakeWatch ;
129154} ) {
130- const tone = toneFor ( outcome , watch ?. kind ) ;
131- const Icon = TONE_ICON [ tone ] ;
155+ const presentation = wakePresentation ( outcome , watch ) ;
156+ const tone = presentation . tone as AgentTone ;
157+ const Icon = SEMANTIC_ICON [ presentation . semanticIcon ] ;
158+ const note = subline ( watch ) ;
159+
132160 return (
133161 < div
134162 className = { cn ( "flex items-start gap-2 rounded-r-md border-l-2 px-3 py-2" , TONE_FRAME [ tone ] ) }
135163 >
136164 < Icon className = { cn ( "mt-0.5 size-4 shrink-0" , TONE_ICON_COLOR [ tone ] ) } />
137165 < div className = "min-w-0" >
138- < p className = "text-sm font-medium text-text-bright" > { wakeHeadline ( outcome , watch , tone ) } </ p >
139- < p className = "truncate text-xs text-text-dimmed" > { subline ( watch ) } </ p >
166+ < p className = "text-xxs font-medium uppercase tracking-wider text-text-dimmed" >
167+ { presentation . label }
168+ </ p >
169+ < p className = "text-sm font-medium text-text-bright" > { presentation . headline } </ p >
170+ { note ? < p className = "truncate text-xs text-text-dimmed" > { note } </ p > : null }
140171 </ div >
141172 </ div >
142173 ) ;
0 commit comments