Skip to content

Commit 1a38ebc

Browse files
committed
Merge branch 'feat/dashboard-agent-flows-watch' into feat/agent-storybook-gallery
2 parents 7d34cf4 + 2c8a396 commit 1a38ebc

11 files changed

Lines changed: 173 additions & 19 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import type { AgentPageContext } from "./page-context-types";
2424
import { retryAction } from "./retry-action";
2525
import {
2626
fetchChatTranscript,
27-
hasOpenInvestigation,
2827
pollSettledTranscript,
28+
transcriptLooksUnfinished,
2929
} from "./settled-transcript";
3030
import { takeNavigateIntent } from "./turn-navigation";
3131
import { sendRequestOutcome } from "./send-request";
@@ -373,8 +373,9 @@ export function DashboardAgentChat({
373373

374374
onTurnSettled();
375375
// The terminal card is written to the chat row after the stream closes, so this
376-
// mounted panel would otherwise keep showing the last `in_progress` revision.
377-
if (!hasOpenInvestigation(messagesRef.current)) return;
376+
// mounted panel would otherwise keep showing the last `in_progress` revision — or,
377+
// if the stream died mid-tool, the tool call it never got an output for.
378+
if (!transcriptLooksUnfinished(messagesRef.current)) return;
378379
void pollSettledTranscript<UIMessage>({
379380
fetchTranscript: () => fetchChatTranscript(actionPath, chatId),
380381
apply: (merge) => setMessages((current) => merge(current)),

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,16 @@ export function DashboardAgentPanel({
513513
try {
514514
const res = await fetch(actionPath, { method: "POST", body });
515515
if (!res.ok) throw new Error(`Watch cancel failed (${res.status})`);
516+
// Empty when the watch had already resolved: then nothing was written.
517+
const data = (await res.json()) as { messages?: UIMessage[] };
518+
if (data.messages?.length) {
519+
const messages = data.messages;
520+
setAppendedMessages((current) => ({
521+
chatId,
522+
messages,
523+
seq: (current?.seq ?? 0) + 1,
524+
}));
525+
}
516526
} catch (error) {
517527
console.error("Dashboard agent: failed to cancel watch", error);
518528
toast.error("We couldn't stop that watch. Try again in a moment.");

apps/webapp/app/components/dashboard-agent/report-sparkline.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -464,10 +464,10 @@ export function ReportProvenance({ uri }: { uri: string }) {
464464
// --- sparkline --------------------------------------------------------------
465465

466466
/** The fixed sparkline column. Keeps every sparkline aligned. */
467-
const SPARK_WIDTH_CLASS = "w-[6.5rem]";
467+
const SPARK_WIDTH_CLASS = "w-[5.5rem]";
468468

469469
/** The chart's own width; the trailing peak label uses the column's remainder. */
470-
const SPARK_WIDTH = 72;
470+
const SPARK_WIDTH = 56;
471471

472472
type ReportSparkDatum = { count: number; date: Date | null; hot: boolean };
473473

@@ -570,18 +570,18 @@ export function ReportSparkline({
570570
* chart start on the same vertical whatever the value's width.
571571
*/
572572
/**
573-
* Below a 22rem container the fixed tracks no longer fit beside the value, so the
573+
* Below a 19rem container the fixed tracks no longer fit beside the value, so the
574574
* sparkline drops to its own line. The columns never change, so the value, delta
575575
* and note stay on the same verticals at every panel width.
576576
*/
577577
const METRIC_ROW_CLASS =
578-
"grid grid-cols-[7rem_minmax(0,1fr)_2.75rem_6.5rem] items-center gap-x-2 @max-[22rem]:grid-cols-[7rem_minmax(0,1fr)_2.75rem] @max-[22rem]:gap-y-1.5";
578+
"grid grid-cols-[6rem_minmax(0,1fr)_2.75rem_5.5rem] items-center gap-x-2 @max-[19rem]:grid-cols-[6rem_minmax(0,1fr)_2.75rem] @max-[19rem]:gap-y-1.5";
579579

580580
/** The sparkline cell: its own full-width line once the row goes narrow. */
581-
const SPARK_CELL_CLASS = "@max-[22rem]:col-span-3 @max-[22rem]:justify-self-end";
581+
const SPARK_CELL_CLASS = "@max-[19rem]:col-span-3 @max-[19rem]:justify-self-end";
582582

583-
// Labels are never truncated: the column is sized for the longest one and
584-
// anything longer wraps.
583+
// Labels are never truncated: the column fits the common ones and anything
584+
// longer wraps.
585585
const LABEL_CLASS = "text-xs uppercase leading-tight tracking-wide text-text-dimmed";
586586

587587
/** A metric's movement against its baseline. Direction is always an arrow. */
@@ -674,7 +674,7 @@ export function ReportMetricRow({
674674
) : (
675675
// Keeps the column occupied so a series-less metric doesn't pull the
676676
// rows out of alignment.
677-
<span aria-hidden className="@max-[22rem]:hidden" />
677+
<span aria-hidden className="@max-[19rem]:hidden" />
678678
)}
679679
</li>
680680

@@ -683,7 +683,7 @@ export function ReportMetricRow({
683683
// vertical as every other row's value.
684684
<li key={sub.label} className={METRIC_ROW_CLASS}>
685685
{/* Indented under the parent label, shallow enough to stay inside the
686-
7rem label column. */}
686+
6rem label column. */}
687687
<span className={cn(LABEL_CLASS, "pl-6")}>{sub.label}</span>
688688
<span className="whitespace-nowrap text-sm tabular-nums text-text-dimmed">
689689
{sub.value}

apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
hasOpenInvestigation,
77
mergeSettledMessages,
88
pollSettledTranscript,
9+
transcriptLooksUnfinished,
910
} from "./settled-transcript";
1011

1112
/**
@@ -119,6 +120,27 @@ describe("reading the transcript endpoint", () => {
119120
});
120121
});
121122

123+
describe("deciding whether a settled turn is worth re-reading", () => {
124+
// The stream EOF'd while `get_report` was running: the part never gets an output.
125+
const DANGLING_TOOL = {
126+
id: "msg_dangling",
127+
role: "assistant",
128+
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
129+
};
130+
131+
it("re-reads when the stream died mid-tool, not only when a card is open", () => {
132+
expect(transcriptLooksUnfinished([DANGLING_TOOL])).toBe(true);
133+
});
134+
135+
it("re-reads while a card is still open", () => {
136+
expect(transcriptLooksUnfinished([OPEN])).toBe(true);
137+
});
138+
139+
it("leaves a fully settled transcript alone", () => {
140+
expect(transcriptLooksUnfinished([OPEN, SETTLED])).toBe(false);
141+
});
142+
});
143+
122144
describe("an already-open panel when a turn is exhausted", () => {
123145
it("stops showing Working… without a reload or a reopen", async () => {
124146
// What the mounted panel holds when the stream closes: the card the model opened

apps/webapp/app/components/dashboard-agent/settled-transcript.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { liveInvestigation } from "./progress-line";
1+
import { inFlightToolName, liveInvestigation } from "./progress-line";
22

33
/**
44
* Re-reading the stored transcript once a turn settles.
@@ -29,6 +29,15 @@ export function hasOpenInvestigation(messages: ReadonlyArray<unknown>): boolean
2929
return liveInvestigation(messages as never) !== null;
3030
}
3131

32+
/**
33+
* Whether the transcript still reads as mid-turn. A stream that dies without
34+
* `turn-complete` leaves the tool part it was on dangling forever, so an open card is
35+
* not the only shape a re-read has to recover from.
36+
*/
37+
export function transcriptLooksUnfinished(messages: ReadonlyArray<unknown>): boolean {
38+
return hasOpenInvestigation(messages) || inFlightToolName(messages as never) !== null;
39+
}
40+
3241
/**
3342
* The settlement is written in `onTurnComplete`, which runs AFTER the client's stream
3443
* closes, so the first re-read can legitimately land before it. Retry a few times,

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import {
3-
cancelWatch,
43
chatExists,
54
countUnreadWatchWakes,
65
countChatsWithUnreadWork,
@@ -36,6 +35,7 @@ import { findProjectBySlug } from "~/models/project.server";
3635
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
3736
import {
3837
authorizeWatchEnvironmentById,
38+
cancelDashboardAgentWatch,
3939
deleteChatWithWatches,
4040
listActiveWatchesForChats,
4141
submitDashboardAgentWatch,
@@ -670,10 +670,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
670670
return json({ error: "Chat not found" }, { status: 404 });
671671
}
672672

673-
// `cancelWatch` only touches an active row, so an already-resolved watch keeps
674-
// its outcome and this is a no-op.
675-
await cancelWatch(dashboardAgentDb, { id: watchId, reason: "user" });
676-
return json({ ok: true });
673+
// Only an active row is cancelled, so an already-resolved watch keeps its outcome,
674+
// this is a no-op and no note is written.
675+
const { messages } = await cancelDashboardAgentWatch({
676+
watchId,
677+
userId,
678+
organizationId: project.organizationId,
679+
});
680+
return json({ ok: true, messages });
677681
}
678682
}
679683
};

apps/webapp/app/services/dashboardAgentWatches.server.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ import {
3030
} from "@internal/dashboard-agent-db";
3131
import {
3232
VIEW_BLOCK_VERSION,
33+
WATCH_CANCELLED_MESSAGE_ID_PREFIX,
3334
WATCH_CONFIRMATION_MESSAGE_ID_PREFIX,
3435
WATCH_REQUEST_MESSAGE_ID_PREFIX,
36+
watchCancelledSentence,
3537
watchConfirmationBlockBody,
3638
watchDraftSchema,
3739
watchIdentity,
@@ -1123,6 +1125,37 @@ export async function scheduleWatchDelivery(watch: { id: string; expiresAt: Date
11231125
);
11241126
}
11251127

1128+
/**
1129+
* Stop a watch the user asked to stop, and say so in the chat that owns it.
1130+
*
1131+
* Only this reason leaves a line: the other cancellations either take the chat with them or
1132+
* already state themselves. `cancelWatch` is guarded on `active`, so a second cancel — or a
1133+
* watch that resolved first — writes nothing, and the id keyed off the watch keeps a retry
1134+
* from adding a second line. Deterministic: no wake, no delivery, no model.
1135+
*/
1136+
export async function cancelDashboardAgentWatch(params: {
1137+
watchId: string;
1138+
userId: string;
1139+
organizationId: string;
1140+
}): Promise<{ cancelled: boolean; messages: WatchTranscriptMessage[] }> {
1141+
const cancelled = await cancelWatch(dashboardAgentDb, { id: params.watchId, reason: "user" });
1142+
if (!cancelled) return { cancelled: false, messages: [] };
1143+
1144+
const message: WatchTranscriptMessage = {
1145+
id: `${WATCH_CANCELLED_MESSAGE_ID_PREFIX}${cancelled.id}`,
1146+
role: "assistant",
1147+
parts: [{ type: "text", text: watchCancelledSentence(cancelled.spec) }],
1148+
};
1149+
await appendChatMessageOnce(dashboardAgentDb, {
1150+
chatId: cancelled.chatId,
1151+
userId: params.userId,
1152+
organizationId: params.organizationId,
1153+
message,
1154+
});
1155+
1156+
return { cancelled: true, messages: [message] };
1157+
}
1158+
11261159
/**
11271160
* Delete a chat and end its watches in one transaction, so no live watch is left on an
11281161
* invisible chat. Owner-scoped, so a chatId the caller doesn't own deletes nothing.

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ process.env.ALERT_EMAIL_TRANSPORT = "smtp";
9393
const {
9494
armDashboardAgentWatchBatch,
9595
authorizeWatchEnvironment,
96+
cancelDashboardAgentWatch,
9697
createDashboardAgentWatch,
9798
deleteChatWithWatches,
9899
listActiveWatchesForChats,
@@ -784,6 +785,68 @@ describe("the chat cascade and the list view", () => {
784785
}
785786
);
786787

788+
postgresTest(
789+
"a user's own cancel leaves one neutral line in the chat, and only one",
790+
async ({ prisma, postgresContainer }) => {
791+
await boot(prisma, postgresContainer.getConnectionUri());
792+
const seeded = await seed(prisma, "usercancel");
793+
await seedChat(seeded, "chat_1");
794+
795+
const created = await create({ seeded, chatId: "chat_1" });
796+
expect(created.ok).toBe(true);
797+
if (!created.ok) return;
798+
799+
const cancel = () =>
800+
cancelDashboardAgentWatch({
801+
watchId: created.watchId,
802+
userId: seeded.user.id,
803+
organizationId: seeded.organization.id,
804+
});
805+
806+
expect(await cancel()).toMatchObject({
807+
cancelled: true,
808+
messages: [
809+
{
810+
id: `watch-cancelled:${created.watchId}`,
811+
role: "assistant",
812+
parts: [{ type: "text", text: "Stopped watching run run_1." }],
813+
},
814+
],
815+
});
816+
expect(await getWatch(ctx.agentDb, { id: created.watchId })).toMatchObject({
817+
status: "cancelled",
818+
cancelReason: "user",
819+
deliveryStatus: "not_required",
820+
});
821+
expect(await storedMessages(seeded, "chat_1")).toMatchObject([
822+
{ id: `watch-cancelled:${created.watchId}`, role: "assistant" },
823+
]);
824+
825+
// The row is no longer active, so the second cancel writes nothing at all.
826+
expect(await cancel()).toEqual({ cancelled: false, messages: [] });
827+
expect(await storedMessages(seeded, "chat_1")).toHaveLength(1);
828+
}
829+
);
830+
831+
postgresTest(
832+
"a chat delete cancels its watches without a line in the chat",
833+
async ({ prisma, postgresContainer }) => {
834+
await boot(prisma, postgresContainer.getConnectionUri());
835+
const seeded = await seed(prisma, "silentcancel");
836+
await seedChat(seeded, "chat_1");
837+
838+
const created = await create({ seeded, chatId: "chat_1" });
839+
expect(created.ok).toBe(true);
840+
841+
await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id });
842+
843+
const rows = await ctx.prisma.$queryRawUnsafe<{ message_id: string }[]>(
844+
`select message_id from trigger_dashboard_agent.chat_messages where chat_id = 'chat_1'`
845+
);
846+
expect(rows).toEqual([]);
847+
}
848+
);
849+
787850
postgresTest(
788851
"aggregates active watches per chat in one query",
789852
async ({ prisma, postgresContainer }) => {

internal-packages/dashboard-agent-contracts/src/watch-wording.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,14 @@ export function watchConditionLabel(spec: WatchSpec): string {
506506
return watchConditionWording(spec).label;
507507
}
508508

509+
/**
510+
* The line a user's own cancel leaves in the transcript. States that the watch
511+
* stopped and nothing about what it saw — it is not a wake.
512+
*/
513+
export function watchCancelledSentence(spec: WatchSpec): string {
514+
return `Stopped watching ${watchSubjectLabel(spec)}.`;
515+
}
516+
509517
/** The Watch button's tooltip. */
510518
export function watchTooltipLabel(spec: WatchSpec): string {
511519
return watchConditionWording(spec).tooltip;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ export const WATCH_REQUEST_MESSAGE_ID_PREFIX = "watch-request:";
136136
/** The transcript id of the confirmation that a watch is running, keyed by the watch. */
137137
export const WATCH_CONFIRMATION_MESSAGE_ID_PREFIX = "watch-confirmation:";
138138

139+
/** The transcript id of the note that the user stopped a watch, keyed by the watch. */
140+
export const WATCH_CANCELLED_MESSAGE_ID_PREFIX = "watch-cancelled:";
141+
139142
/**
140143
* A deterministic consent record, not a turn the user spent, so it never counts
141144
* against the message cap and the retry button never resends it.

0 commit comments

Comments
 (0)