Skip to content

Commit 2cd73ec

Browse files
committed
fix(webapp): the wake banner stops restating what the text says
1 parent 676aee9 commit 2cd73ec

5 files changed

Lines changed: 55 additions & 54 deletions

File tree

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

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/**
2-
* The banner above a wake narration.
2+
* The banner above a wake narration: the label, the icon and the tone frame, and
3+
* nothing else. The narration under it states the headline, the user's note and the
4+
* next step, and each of those is said once per wake — so the banner marks the
5+
* message as a wake rather than restating it.
36
*
4-
* This component holds no kind-specific wording: tone, semantic icon and headline
5-
* come from contracts and `app/presenters/v3/dashboardAgent`. All it decides is which glyph a
7+
* This component holds no kind-specific wording: tone and semantic icon come from
8+
* contracts and `app/presenters/v3/dashboardAgent`. All it decides is which glyph a
69
* semantic icon draws and which frame a tone paints.
710
*
811
* A wake is identified by its message id, `wake:watch:{watchId}:{fired|expired}`.
@@ -23,11 +26,7 @@ import type {
2326
} from "@internal/dashboard-agent-contracts";
2427
import { cn } from "~/utils/cn";
2528
import { type AgentTone, TONE_ICON_COLOR } from "./agent-badges";
26-
import {
27-
presentResolvedWatch,
28-
watchSubline,
29-
WATCH_PRESENTATION_FALLBACK,
30-
} from "~/presenters/v3/dashboardAgent";
29+
import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "~/presenters/v3/dashboardAgent";
3130

3231
const WAKE_ID_PREFIX = "wake:watch:";
3332

@@ -137,20 +136,15 @@ export function WakeBanner({
137136
const presentation = wakePresentation(outcome, watch);
138137
const tone = presentation.tone as AgentTone;
139138
const Icon = SEMANTIC_ICON[presentation.semanticIcon];
140-
const note = watchSubline(watch);
141139

142140
return (
143141
<div
144-
className={cn("flex items-start gap-2 rounded-r-md border-l-2 px-3 py-2", TONE_FRAME[tone])}
142+
className={cn("flex items-center gap-2 rounded-r-md border-l-2 px-3 py-2", TONE_FRAME[tone])}
145143
>
146-
<Icon className={cn("mt-0.5 size-4 shrink-0", TONE_ICON_COLOR[tone])} />
147-
<div className="min-w-0">
148-
<p className="text-xxs font-medium uppercase tracking-wider text-text-dimmed">
149-
{presentation.label}
150-
</p>
151-
<p className="text-sm font-medium text-text-bright">{presentation.headline}</p>
152-
{note ? <p className="truncate text-xs text-text-dimmed">{note}</p> : null}
153-
</div>
144+
<Icon className={cn("size-4 shrink-0", TONE_ICON_COLOR[tone])} />
145+
<p className="text-xxs font-medium uppercase tracking-wider text-text-dimmed">
146+
{presentation.label}
147+
</p>
154148
</div>
155149
);
156150
}

internal-packages/dashboard-agent/src/watch-actions.test.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,10 @@ describe("watch wake narration", () => {
116116

117117
const first = await harness.sendAction(WAKE);
118118
// A drained queue is a fact the check already established, so the sentence is the
119-
// dashboard's own wording and no model is called for it. The banner states the
120-
// headline, so the wake only says what happens next.
121-
expect(collectText(first.chunks)).toBe("Nothing to do — I've stopped watching.");
119+
// dashboard's own wording and no model is called for it.
120+
expect(collectText(first.chunks)).toBe(
121+
"task/send-receipt queue drained\n\nNothing to do — I've stopped watching it."
122+
);
122123

123124
// The streamed message must carry the same id the read-model copy is persisted
124125
// under, or the panel renders the narration twice.
@@ -250,11 +251,8 @@ describe("watch wake narration", () => {
250251
facts: { reason: "terminal_unsatisfied" },
251252
});
252253

253-
// Read as `condition_impossible`: only that resolution is neutral here, and only a
254-
// neutral outcome offers another watch instead of reporting a problem.
255-
expect(collectText(wake.chunks)).toBe(
256-
"I've stopped watching. Ask me if you want another watch set up."
257-
);
254+
// Read as `condition_impossible`: only that resolution says the queue is gone.
255+
expect(collectText(wake.chunks)).toContain("task/send-receipt queue no longer exists");
258256
expect(wakeText(prompts)).toBe("[]");
259257
});
260258

@@ -465,7 +463,7 @@ describe("watch wake narration", () => {
465463
const first = await harness.sendAction(WAKE);
466464
// Streamed — so it is on `session.out` and in the next boot's history — while the
467465
// row it was supposed to land alongside never arrived.
468-
expect(collectText(first.chunks)).toContain("I've stopped watching");
466+
expect(collectText(first.chunks)).toContain("queue drained");
469467
expect(failing.calls.appendMessage).toHaveLength(1);
470468
expect(table.countOf(chatId, wakeId)).toBe(0);
471469
const durable = first.chunks;

internal-packages/dashboard-agent/src/watch-actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ async function narrateWatchWake(args: {
510510
resolution: wakeResolution(action),
511511
observed: action.observed,
512512
note: action.note,
513+
subjectLink: wakeSubjectLink(action, tenancy),
513514
startsInvestigation: wakeStartsInvestigation(action),
514515
});
515516
logger.info("dashboard-agent watch wake narration lane", {

internal-packages/dashboard-agent/src/watch-narration.test.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,20 @@ describe("which model narrates a wake", () => {
3333
const plan = planWatchNarration(DRAINED);
3434
expect(plan.model).toBe("none");
3535
if (plan.model !== "none") throw new Error("unreachable");
36-
// Just what to do. The banner above the wake already states the headline and
37-
// the user's reason, and a wake says each fact once.
38-
expect(plan.text).toBe("Nothing to do — I've stopped watching.");
39-
expect(plan.presentation.headline).toBe("task/send-receipt queue drained");
36+
// The dashboard's own sentence, the user's reason, then what to do — each once.
37+
// The banner above the wake carries the label and nothing else.
38+
expect(plan.text.split("\n\n")).toEqual([
39+
"task/send-receipt queue drained",
40+
"You asked to be told when: tell me when the backlog drains",
41+
"Nothing to do — I've stopped watching it.",
42+
]);
4043
});
4144

4245
it("needs no model when the answer is that the watched thing is gone", () => {
4346
const plan = planWatchNarration({ ...DRAINED, resolution: "condition_impossible" });
4447
expect(plan.model).toBe("none");
4548
if (plan.model !== "none") throw new Error("unreachable");
46-
expect(plan.presentation.headline).toContain("no longer exists");
47-
});
48-
49-
it("never repeats the headline or the note the banner already states", () => {
50-
const { text } = deterministicWakeNarration(DRAINED);
51-
expect(text).not.toContain("queue drained");
52-
expect(text).not.toContain("tell me when the backlog drains");
53-
expect(text).not.toContain("You asked to be told when");
49+
expect(plan.text).toContain("no longer exists");
5450
});
5551

5652
it("uses Haiku when the fact has to be turned into what to do", () => {
@@ -64,10 +60,15 @@ describe("which model narrates a wake", () => {
6460
expect(planWatchNarration({ ...DRAINED, startsInvestigation: true }).model).toBe("sonnet");
6561
});
6662

67-
it("never names the watched object again — the banner just named it", () => {
68-
const { text } = deterministicWakeNarration(DRAINED);
69-
expect(text).not.toContain("task/send-receipt");
70-
expect(text).not.toContain("trigger://");
63+
it("links the watched object once, on the line that acts on it", () => {
64+
const { text } = deterministicWakeNarration({
65+
...DRAINED,
66+
subjectLink: "[task/send-receipt](trigger://queue/proj_abc/env_abc/task%2Fsend-receipt)",
67+
});
68+
expect(text).toContain("I've stopped watching [task/send-receipt]");
69+
// The headline already names the queue, so it is not followed by the link too.
70+
expect(text.split("\n\n")[0]).toBe("task/send-receipt queue drained");
71+
expect(text.match(/trigger:\/\//g)).toHaveLength(1);
7172
});
7273

7374
it("never says fired or expired", () => {

internal-packages/dashboard-agent/src/watch-narration.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
presentResolvedWatch,
3+
watchNoteLine,
34
type WatchObservedOutcome,
45
type WatchPresentation,
56
type WatchResolution,
@@ -33,6 +34,8 @@ export type NarratableWake = {
3334
resolution: WatchResolution;
3435
observed?: WatchObservedOutcome;
3536
note?: string;
37+
/** The watched thing as a `trigger://` markdown link, when the tenancy allows one. */
38+
subjectLink?: string;
3639
/** The user pre-approved an investigation and it has already been started. */
3740
startsInvestigation: boolean;
3841
};
@@ -42,25 +45,21 @@ export type NarratableWake = {
4245
* exactly the judgement a model is for, and this lane is the one where there is
4346
* nothing to judge.
4447
*/
45-
function nextStep(presentation: WatchPresentation): string {
48+
function nextStep(presentation: WatchPresentation, subjectLink?: string): string {
49+
const subject = subjectLink ?? "it";
4650
switch (presentation.category) {
4751
case "positive":
48-
return "Nothing to do — I've stopped watching.";
52+
return `Nothing to do — I've stopped watching ${subject}.`;
4953
// A neutral outcome is an answer without a problem in it: the watched thing is
5054
// gone, cancelled, or was never readable.
5155
case "neutral":
52-
return "I've stopped watching. Ask me if you want another watch set up.";
56+
return `I've stopped watching ${subject}. Ask me if you want another watch set up.`;
5357
case "attention":
54-
return "Ask me to look into it if you want the why.";
58+
return `Ask me to look into ${subject} if you want the why.`;
5559
}
5660
}
5761

58-
/**
59-
* The whole wake message, when no model is needed.
60-
*
61-
* Only the next step: the wake banner above it already states the headline and the
62-
* user's note, and each fact is said once per wake.
63-
*/
62+
/** The whole wake message, when no model is needed. */
6463
export function deterministicWakeNarration(wake: NarratableWake): {
6564
text: string;
6665
presentation: WatchPresentation;
@@ -71,7 +70,15 @@ export function deterministicWakeNarration(wake: NarratableWake): {
7170
resolution: wake.resolution,
7271
observed: wake.observed ?? null,
7372
});
74-
return { text: nextStep(presentation), presentation };
73+
const lines = [
74+
// The headline is already a complete fact, and every surface states it this way.
75+
// The subject is not repeated after it — the next step links it, once.
76+
presentation.headline,
77+
wake.note ? watchNoteLine(wake.note) : null,
78+
nextStep(presentation, wake.subjectLink),
79+
].filter((line): line is string => Boolean(line));
80+
81+
return { text: lines.join("\n\n"), presentation };
7582
}
7683

7784
/**

0 commit comments

Comments
 (0)