Skip to content

Commit a303cb0

Browse files
committed
refactor(webapp): one presenter for all watch and view-block wording
Every watch sentence (card, banner, toast, email, Slack, webhook) now comes from app/presenters/v3/dashboardAgent. The condition is written once per kind in four registers instead of in four switches across three files, and a plain-text block renderer lets the non-React surfaces say what the card says.
1 parent 0468776 commit a303cb0

18 files changed

Lines changed: 758 additions & 156 deletions

apps/webapp/app/components/dashboard-agent/WakeBanner.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* The banner above a wake narration.
33
*
44
* This component holds no kind-specific wording: tone, semantic icon and headline
5-
* come from contracts and `watch-presentation.ts`. All it decides is which glyph a
5+
* come from contracts and `app/presenters/v3/dashboardAgent`. All it decides is which glyph a
66
* semantic icon draws and which frame a tone paints.
77
*
88
* A wake is identified by its message id, `wake:watch:{watchId}:{fired|expired}`.
@@ -23,7 +23,11 @@ import type {
2323
} from "@internal/dashboard-agent-contracts";
2424
import { cn } from "~/utils/cn";
2525
import { type AgentTone, TONE_ICON_COLOR } from "./agent-badges";
26-
import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "./watch-presentation";
26+
import {
27+
presentResolvedWatch,
28+
watchSubline,
29+
WATCH_PRESENTATION_FALLBACK,
30+
} from "~/presenters/v3/dashboardAgent";
2731

2832
const WAKE_ID_PREFIX = "wake:watch:";
2933

@@ -121,15 +125,6 @@ const TONE_FRAME: Record<AgentTone, string> = {
121125
error: "border-l-error bg-error/10",
122126
};
123127

124-
/** What the watch was for: the user's own words, else whatever names it. */
125-
function subline(watch: WakeWatch | undefined): string | null {
126-
const note = watch?.note.trim();
127-
if (note) return note;
128-
if (watch?.identity) return watch.identity;
129-
if (watch?.kind) return watch.kind;
130-
return null;
131-
}
132-
133128
export function WakeBanner({
134129
outcome,
135130
watch,
@@ -142,7 +137,7 @@ export function WakeBanner({
142137
const presentation = wakePresentation(outcome, watch);
143138
const tone = presentation.tone as AgentTone;
144139
const Icon = SEMANTIC_ICON[presentation.semanticIcon];
145-
const note = subline(watch);
140+
const note = watchSubline(watch);
146141

147142
return (
148143
<div

apps/webapp/app/components/dashboard-agent/WatchButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EyeIcon } from "@heroicons/react/20/solid";
22
import type { WatchSpec } from "@internal/dashboard-agent-contracts";
33
import { Button } from "~/components/primitives/Buttons";
44
import { useDashboardAgent } from "./dashboardAgentLauncher";
5-
import { watchTooltipLabel } from "./watch-presentation";
5+
import { watchTooltipLabel } from "~/presenters/v3/dashboardAgent";
66

77
/**
88
* The universal Watch action, used by runs, queues, errors and health.

apps/webapp/app/components/dashboard-agent/WatchCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* and never become a radio group.
99
*
1010
* Pure component: draft in, markup and callbacks out. Draft rules live in
11-
* `watch-card.ts` and wording in `watch-presentation.ts`.
11+
* `watch-card.ts` and wording in `app/presenters/v3/dashboardAgent`.
1212
*/
1313
import { EyeIcon } from "@heroicons/react/20/solid";
1414
import {
@@ -41,7 +41,7 @@ import {
4141
watchConditionLabel,
4242
watchDurationLabel,
4343
watchSubjectLabel,
44-
} from "./watch-presentation";
44+
} from "~/presenters/v3/dashboardAgent";
4545

4646
/** How the condition variants are named in the picker. Short, not sentences. */
4747
const VARIANT_LABEL: Record<WatchKind, string> = {

apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* cancel.
88
*
99
* Pure component: the wording is not computed here, it was frozen into the block at
10-
* append time by `watch-presentation.ts`, so a later copy change never rewrites what
10+
* append time by `app/presenters/v3/dashboardAgent`, so a later copy change never rewrites what
1111
* a user was already told.
1212
*/
1313
import { CheckCircleIcon, EyeIcon, InformationCircleIcon } from "@heroicons/react/20/solid";

apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Button } from "~/components/primitives/Buttons";
1111
import { ToastUI } from "~/components/primitives/Toast";
1212
import type { WatchObservedOutcome, WatchResolution } from "@internal/dashboard-agent-contracts";
1313
import { wakeResolution } from "./WakeBanner";
14-
import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "./watch-presentation";
14+
import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "~/presenters/v3/dashboardAgent";
1515

1616
/** Matches sonner's default toast width, same as the app's other toasts. */
1717
const TOAST_WIDTH = 356;
@@ -41,7 +41,7 @@ export type WatchWake = {
4141

4242
/**
4343
* The toast's title: the fact, or the neutral fallback when this wake predates the
44-
* resolution model. The wording is `watch-presentation.ts`'s; this only decides
44+
* resolution model. The wording is `app/presenters/v3/dashboardAgent`'s; this only decides
4545
* which watch to ask it about.
4646
*/
4747
export function watchWakeToastTitle(wake: WatchWake): string {

apps/webapp/app/components/dashboard-agent/watch-card.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
watchDurationLabel,
2626
watchOneShotBlockBody,
2727
watchSubjectLabel,
28-
} from "./watch-presentation";
28+
} from "~/presenters/v3/dashboardAgent";
2929
import {
3030
errorWatchRecommendation,
3131
healthWatchRecommendation,
@@ -145,9 +145,11 @@ describe("condition variants (§3)", () => {
145145

146146
it("restates the note when the threshold number changes", () => {
147147
const above = withThreshold(withVariant(queueDraft(), "queue_depth_above"), 500);
148-
expect(above.spec.note).toBe("tell me if the email-sends queue grows above 500");
148+
// Same verb and same SLA format as the card's condition line: both come from
149+
// the presenter's one wording record.
150+
expect(above.spec.note).toBe("tell me if the email-sends queue goes above 500");
149151
const age = withAgeMinutes(withVariant(queueDraft(), "queue_oldest_age"), 90);
150-
expect(age.spec.note).toBe("tell me if runs in email-sends wait longer than 90 minutes");
152+
expect(age.spec.note).toBe("tell me if runs in email-sends wait longer than 1h 30m");
151153
});
152154

153155
it("gives the threshold variant a usable default", () => {

apps/webapp/app/components/dashboard-agent/watch-card.ts

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type WatchKind,
2525
type WatchSpec,
2626
} from "@internal/dashboard-agent-contracts";
27+
import { noteFor } from "~/presenters/v3/dashboardAgent";
2728

2829
/** A brand-new draft: the recommendation, with both opt-ins off. */
2930
export function watchDraftFor(spec: WatchSpec): WatchDraft {
@@ -40,36 +41,6 @@ export function clampCadence(kind: WatchKind, minutes: number): number {
4041
return options.find((option) => option >= minutes) ?? options[options.length - 1]!;
4142
}
4243

43-
/**
44-
* The note, restated from the spec. The wake narration quotes the note, so
45-
* changing the condition or its number must rewrite it. Edits that keep the
46-
* condition (window, cadence) keep the user's own words.
47-
*/
48-
export function noteFor(spec: WatchSpec): string {
49-
switch (spec.kind) {
50-
case "run_start":
51-
return `tell me when run ${spec.runId} starts`;
52-
case "run_finished":
53-
return `tell me when run ${spec.runId} finishes`;
54-
case "run_failed":
55-
return `tell me if run ${spec.runId} fails`;
56-
case "backlog_drain":
57-
return `tell me when the ${spec.queue} queue drains`;
58-
case "queue_depth_above":
59-
return `tell me if the ${spec.queue} queue grows above ${spec.threshold}`;
60-
case "queue_depth_below":
61-
return `tell me when the ${spec.queue} queue is back below ${spec.threshold}`;
62-
case "queue_stalled":
63-
return `tell me if the ${spec.queue} queue stops moving`;
64-
case "queue_oldest_age":
65-
return `tell me if runs in ${spec.queue} wait longer than ${spec.thresholdMinutes} minutes`;
66-
case "error_recurrence":
67-
return `ping me if error ${spec.fingerprint} happens again`;
68-
case "health_recovery":
69-
return "tell me when health is back to normal";
70-
}
71-
}
72-
7344
/**
7445
* Swap the condition for its sibling variant, carrying everything else except the
7546
* note, which is restated to describe the new condition.

apps/webapp/app/components/dashboard-agent/watch-chips.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import type { WatchStatus } from "@internal/dashboard-agent-contracts";
1010

1111
// The immediate-check wording lives in the presenter with the rest of the
1212
// user-facing copy. Re-exported here for chip callers.
13-
export { immediateWatchMessage } from "./watch-presentation";
13+
export { immediateWatchMessage } from "~/presenters/v3/dashboardAgent";
1414

15-
import { formatWatchCadence, watchIdentityValue } from "./watch-presentation";
15+
import { formatWatchCadence, watchIdentityValue } from "~/presenters/v3/dashboardAgent";
1616

1717
export const WATCH_STATUS_LABEL: Record<WatchStatus, string> = {
1818
active: "watching",

apps/webapp/app/components/dashboard-agent/watch-recommendations.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,28 @@ import {
1212
type WatchSpec,
1313
} from "@internal/dashboard-agent-contracts";
1414
import { OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
15+
import { noteFor } from "~/presenters/v3/dashboardAgent";
16+
17+
/** Distributes over the spec union, so the kind stays discriminated. */
18+
type WithoutNote<T> = T extends unknown ? Omit<T, "note"> : never;
19+
20+
/** The note comes from the presenter, so a recommendation reads like an edited one. */
21+
function withNote(spec: WithoutNote<WatchSpec>): WatchSpec {
22+
const draft = { ...spec, note: "" } as WatchSpec;
23+
return { ...draft, note: noteFor(draft) };
24+
}
1525

1626
/**
1727
* A run is the one object worth checking every minute: it is a single row read, and
1828
* a run that lands in ninety seconds should not be reported five minutes late.
1929
*/
2030
export function runWatchRecommendation(runFriendlyId: string): WatchSpec {
21-
return {
31+
return withNote({
2232
kind: "run_finished",
2333
runId: runFriendlyId,
2434
checkEveryMinutes: 1,
2535
maxHours: 1,
26-
note: `tell me when run ${runFriendlyId} finishes`,
27-
};
36+
});
2837
}
2938

3039
/**
@@ -42,13 +51,12 @@ export function queueWatchRecommendation(
4251
): WatchSpec {
4352
const oldestWaitMs = context?.oldestWaitMs ?? null;
4453
if (oldestWaitMs !== null && oldestWaitMs >= OLDEST_WAIT_WARNING_MS) {
45-
return {
54+
return withNote({
4655
kind: "backlog_drain",
4756
queue: queueName,
4857
checkEveryMinutes: 5,
4958
maxHours: 1,
50-
note: `tell me when the ${queueName} queue drains`,
51-
};
59+
});
5260
}
5361

5462
return queueAgeWatchRecommendation(queueName);
@@ -59,41 +67,38 @@ export function queueAgeWatchRecommendation(
5967
queueName: string,
6068
thresholdMinutes: number = WATCH_DEFAULT_QUEUE_AGE_MINUTES
6169
): WatchSpec {
62-
return {
70+
return withNote({
6371
kind: "queue_oldest_age",
6472
queue: queueName,
6573
thresholdMinutes,
6674
checkEveryMinutes: 5,
6775
maxHours: 1,
68-
note: `tell me if runs in ${queueName} wait longer than ${thresholdMinutes} minutes`,
69-
};
76+
});
7077
}
7178

7279
/**
7380
* A recurrence needs room to happen, so the window is longer than the other three:
7481
* plenty of errors come back within the working day rather than in ten minutes.
7582
*/
7683
export function errorWatchRecommendation(errorFriendlyId: string): WatchSpec {
77-
return {
84+
return withNote({
7885
kind: "error_recurrence",
7986
fingerprint: errorFriendlyId,
8087
checkEveryMinutes: 5,
8188
maxHours: 6,
82-
note: `ping me if error ${errorFriendlyId} happens again`,
83-
};
89+
});
8490
}
8591

8692
/**
8793
* Only offered on a degraded report: a recovery watch is meaningless while
8894
* everything is fine. `fromSeverity` is the state the recovery is measured from.
8995
*/
9096
export function healthWatchRecommendation(fromSeverity: "warn" | "crit"): WatchSpec {
91-
return {
97+
return withNote({
9298
kind: "health_recovery",
9399
report: "health",
94100
fromSeverity,
95101
checkEveryMinutes: 5,
96102
maxHours: 2,
97-
note: "tell me when health is back to normal",
98-
};
103+
});
99104
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* A view block or a resolved watch as plain text.
3+
*
4+
* The panel renders blocks as React; an email, a Slack message, a webhook body or
5+
* a log line cannot. Rather than each of those re-saying the block's contents in
6+
* its own words, they render it here. Pure, no React, no request context.
7+
*/
8+
import type { ViewBlock } from "@internal/dashboard-agent-contracts";
9+
import { presentResolvedWatch, watchNoteLine, type WatchResolvedInput } from "./watch-wording";
10+
11+
/** A labelled scalar the check observed. */
12+
export type TextFact = { label: string; value: string };
13+
14+
/** Facts as one `Label: value` line each. */
15+
export function renderFactLines(facts: readonly TextFact[]): string[] {
16+
return facts.map((fact) => `${fact.label}: ${fact.value}`);
17+
}
18+
19+
function lines(...parts: Array<string | null | undefined | false>): string {
20+
return parts
21+
.filter((part): part is string => typeof part === "string" && part.length > 0)
22+
.join("\n");
23+
}
24+
25+
/**
26+
* One view block as plain text. Says what the card says and nothing more — no
27+
* surface may add a sentence of its own on top.
28+
*/
29+
export function renderBlockAsText(block: ViewBlock): string {
30+
switch (block.type) {
31+
case "watch_result":
32+
return lines(block.headline, block.lifetime, block.detail, ...block.followUp);
33+
34+
case "diagnosis":
35+
return lines(
36+
block.summary,
37+
`Likely cause: ${block.likelyCause}`,
38+
`Confidence: ${block.confidence}`,
39+
block.impact ? `Impact: ${block.impact}` : null,
40+
...block.evidence.map(
41+
(item) =>
42+
`Evidence (${item.type}): ${item.detail}${item.reference ? ` — ${item.reference}` : ""}`
43+
),
44+
...block.nextSteps.map((step, index) => `${index + 1}. ${step}`)
45+
);
46+
47+
case "investigation": {
48+
const state = block.investigation;
49+
return lines(
50+
state.title,
51+
state.headline,
52+
`Outcome: ${state.outcome} · severity ${state.severity} · confidence ${state.confidence}`,
53+
...state.hypotheses.map(
54+
(hypothesis) =>
55+
`${hypothesis.statement}${hypothesis.verdict}${
56+
hypothesis.finding ? `: ${hypothesis.finding}` : ""
57+
}`
58+
),
59+
state.remediation ? `Fix: ${state.remediation}` : null,
60+
...(state.checkNext ?? []).map((step) => `Check next: ${step}`),
61+
state.caveat ? `Caveat: ${state.caveat.message}` : null
62+
);
63+
}
64+
65+
case "report": {
66+
const { vm } = block;
67+
return lines(
68+
`${vm.title} report for ${vm.scope} (${vm.period}): ${vm.summary.severity}`,
69+
...vm.findings.map((finding) => `${finding.type}${finding.severity}: ${finding.reason}`)
70+
);
71+
}
72+
73+
// A chart is its shape, not its rows: the rows come from running the query.
74+
case "chart":
75+
return lines(`Chart: ${block.title ?? "untitled"} (${block.chartType})`, block.query);
76+
77+
case "actions":
78+
return lines(...block.actions.map((action) => `- ${action.label}`));
79+
80+
default: {
81+
const unreachable: never = block;
82+
throw new Error(`Unhandled view block: ${JSON.stringify(unreachable)}`);
83+
}
84+
}
85+
}
86+
87+
/**
88+
* A resolved watch as plain text: the fact, why it was being watched, then what the
89+
* resolving check saw. What the email body and the Slack message both say.
90+
*/
91+
export function renderResolvedWatchAsText(args: {
92+
resolved: WatchResolvedInput;
93+
note: string;
94+
facts: readonly TextFact[];
95+
}): string {
96+
const { headline } = presentResolvedWatch(args.resolved);
97+
return lines(headline, watchNoteLine(args.note), ...renderFactLines(args.facts));
98+
}

0 commit comments

Comments
 (0)