From 09e12c70da483b2e17606182f8230755234427ae Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:01:20 +0000 Subject: [PATCH] fix(dashboard): Prioritize user messages after work finishes Co-Authored-By: David Cramer --- packages/junior/src/api/conversations/list.ts | 6 +++ .../junior/src/api/conversations/priority.ts | 10 +++- .../conversations/user-message-activity.ts | 46 +++++++++++++++++++ .../api/conversations/list.test.ts | 38 +++++++++++++++ .../unit/api/conversations/priority.test.ts | 16 ++++++- 5 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 packages/junior/src/api/conversations/user-message-activity.ts diff --git a/packages/junior/src/api/conversations/list.ts b/packages/junior/src/api/conversations/list.ts index 4a04183f1..97fd81873 100644 --- a/packages/junior/src/api/conversations/list.ts +++ b/packages/junior/src/api/conversations/list.ts @@ -25,6 +25,7 @@ import { readRootConversationMetricsFromSql } from "./usage"; import { readConversationAuxiliaryCostsFromSql } from "./auxiliary-costs"; import { listConversationWork } from "@/chat/plugins/unfinished-work"; import { isConversationPriority } from "./priority"; +import { readLastUserMessageAtByConversation } from "./user-message-activity"; const CONVERSATION_FEED_LIMIT = 50; @@ -245,6 +246,7 @@ export async function readConversationFeedFromSql( metricsByRoot, teamDomainByTeamId, conversationWork, + lastUserMessageAtByConversation, ] = await Promise.all([ readConversationAccessFromSql(db, conversationIds, options.viewer), readConversationAuxiliaryCostsFromSql(db, conversationIds, { @@ -259,6 +261,7 @@ export async function readConversationFeedFromSql( ), ), listConversationWork(conversationIds), + readLastUserMessageAtByConversation(db, conversationIds), ]); const assignedWork = new Set(conversationWork.assignedIds); const unfinishedWork = new Set(conversationWork.unfinishedIds); @@ -297,6 +300,9 @@ export async function readConversationFeedFromSql( isPriority: isConversationPriority( { lastSeenAt: summary.lastSeenAt, + lastUserMessageAt: lastUserMessageAtByConversation.get( + conversation.conversationId, + ), ...work, }, nowMs, diff --git a/packages/junior/src/api/conversations/priority.ts b/packages/junior/src/api/conversations/priority.ts index 689601111..0938ae01c 100644 --- a/packages/junior/src/api/conversations/priority.ts +++ b/packages/junior/src/api/conversations/priority.ts @@ -7,6 +7,7 @@ export type ConversationPriorityInput = { assignedWork?: boolean; finishedWorkAt?: string; lastSeenAt: string; + lastUserMessageAt?: string; unfinishedWork?: boolean; }; @@ -15,7 +16,7 @@ export type ConversationPriorityInput = { * * Priority includes only: * - unfinished work last seen within 48 hours - * - finished assigned work with conversation activity after the finish time + * - finished assigned work with a user message after the finish time * - no known work last seen within 3 hours * * Finished assigned work with no later activity stays out of Priority. @@ -33,7 +34,12 @@ export function isConversationPriority( if (conversation.assignedWork) { const finishedAt = Date.parse(conversation.finishedWorkAt ?? ""); - return Number.isFinite(finishedAt) && lastSeenAt > finishedAt; + const lastUserMessageAt = Date.parse(conversation.lastUserMessageAt ?? ""); + return ( + Number.isFinite(finishedAt) && + Number.isFinite(lastUserMessageAt) && + lastUserMessageAt > finishedAt + ); } return nowMs - lastSeenAt <= UNASSIGNED_PRIORITY_WINDOW_MS; diff --git a/packages/junior/src/api/conversations/user-message-activity.ts b/packages/junior/src/api/conversations/user-message-activity.ts new file mode 100644 index 000000000..24781fedd --- /dev/null +++ b/packages/junior/src/api/conversations/user-message-activity.ts @@ -0,0 +1,46 @@ +import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; +import type { JuniorDatabase } from "@/db/db"; +import { juniorConversationEvents } from "@/db/schema"; + +/** Read the latest true user message time for each selected conversation. */ +export async function readLastUserMessageAtByConversation( + db: JuniorDatabase, + conversationIds: string[], +): Promise> { + if (conversationIds.length === 0) return new Map(); + + const authority = sql`${juniorConversationEvents.payload}->'provenance'->>'authority'`; + const actorPlatform = sql`${juniorConversationEvents.payload}->'provenance'->'actor'->>'platform'`; + const role = sql`${juniorConversationEvents.payload}->>'role'`; + const rows = await db + .select({ + conversationId: juniorConversationEvents.conversationId, + lastUserMessageAt: sql`max(${juniorConversationEvents.createdAt})`, + }) + .from(juniorConversationEvents) + .where( + and( + inArray(juniorConversationEvents.conversationId, conversationIds), + or( + and( + eq(juniorConversationEvents.type, "user_message"), + eq(authority, "instruction"), + sql`${actorPlatform} in ('slack', 'local', 'web')`, + ), + and( + eq(juniorConversationEvents.type, "message"), + eq(role, "user"), + isNotNull(juniorConversationEvents.actorIdentityId), + ), + ), + ), + ) + .groupBy(juniorConversationEvents.conversationId); + + return new Map( + rows.map((row) => [ + row.conversationId, + new Date(row.lastUserMessageAt).toISOString(), + ]), + ); +} diff --git a/packages/junior/tests/integration/api/conversations/list.test.ts b/packages/junior/tests/integration/api/conversations/list.test.ts index e44dd4d67..32bf41c3d 100644 --- a/packages/junior/tests/integration/api/conversations/list.test.ts +++ b/packages/junior/tests/integration/api/conversations/list.test.ts @@ -73,6 +73,44 @@ describe("conversation list API", () => { conversationId: finishedId, nowMs: nowMs - 120_000, }); + await fixture.sql.db().insert(juniorConversationEvents).values([ + { + conversationId: finishedUpdatedId, + createdAt: new Date(nowMs - 30_000), + historyVersion: 0, + payload: { + content: "Please follow up.", + provenance: { + authority: "instruction", + actor: { + platform: "slack", + teamId: "T123", + userId: "U123456", + }, + }, + timestamp: nowMs - 30_000, + }, + schemaVersion: 1, + seq: 0, + type: "user_message", + }, + { + conversationId: finishedId, + createdAt: new Date(nowMs - 30_000), + historyVersion: 0, + payload: { + content: "Pull request merged.", + provenance: { + authority: "instruction", + actor: { name: "resource-event", platform: "system" }, + }, + timestamp: nowMs - 30_000, + }, + schemaVersion: 1, + seq: 0, + type: "user_message", + }, + ]); setPlugins([ defineJuniorPlugin({ manifest: { diff --git a/packages/junior/tests/unit/api/conversations/priority.test.ts b/packages/junior/tests/unit/api/conversations/priority.test.ts index 1b8aaa37e..690405e87 100644 --- a/packages/junior/tests/unit/api/conversations/priority.test.ts +++ b/packages/junior/tests/unit/api/conversations/priority.test.ts @@ -26,13 +26,14 @@ describe("isConversationPriority", () => { ).toBe(false); }); - it("keeps finished assigned work only when activity is after the finish time", () => { + it("keeps finished assigned work only when a user message is after the finish time", () => { expect( isConversationPriority( { assignedWork: true, finishedWorkAt: "2026-08-03T17:00:00.000Z", lastSeenAt: "2026-08-03T18:00:00.000Z", + lastUserMessageAt: "2026-08-03T17:30:00.000Z", }, NOW_MS, ), @@ -41,7 +42,18 @@ describe("isConversationPriority", () => { isConversationPriority( { assignedWork: true, - finishedWorkAt: "2026-08-03T18:00:00.000Z", + finishedWorkAt: "2026-08-03T17:00:00.000Z", + lastSeenAt: "2026-08-03T18:00:00.000Z", + lastUserMessageAt: "2026-08-03T16:30:00.000Z", + }, + NOW_MS, + ), + ).toBe(false); + expect( + isConversationPriority( + { + assignedWork: true, + finishedWorkAt: "2026-08-03T17:00:00.000Z", lastSeenAt: "2026-08-03T18:00:00.000Z", }, NOW_MS,