Skip to content

Commit 4d695bb

Browse files
committed
feat(webapp): a user-cancelled watch says so in the chat
1 parent aaa9c9c commit 4d695bb

7 files changed

Lines changed: 128 additions & 6 deletions

File tree

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/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.

internal-packages/dashboard-agent/GUIDEBOOK.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,12 +389,13 @@ deliver — otherwise they would stay active forever — and leaves the wake owe
389389
## What ends a watch without an answer
390390

391391
A watch reaches `fired` or `expired` by resolving, and both deliver a wake.
392-
**Cancellation is the silent ending: no resolution, no wake, nothing to read.**
392+
**Cancellation is the ending without an answer: no resolution and never a wake.**
393393
The five reasons (`watch-schema.ts`):
394394

395395
| Reason | When |
396396
| --- | --- |
397397
| `user` | the chip's cancel, scoped through the chat: the watch must belong to that chat and the chat to this user in this org. A no-op if it already resolved |
398+
| | Alone among the reasons, it leaves one neutral line in the transcript ("Stopped watching …"), keyed off the watch id so a retry can't repeat it. Still no wake, no delivery |
398399
| `chat_deleted` | deleting a chat cancels its watches in the same transaction, so live watches can't outlive a chat the user can no longer see |
399400
| `access_revoked` | the creator's access no longer holds — checked before any read |
400401
| `scheduling_failed` | the first tick couldn't be scheduled |

0 commit comments

Comments
 (0)