Skip to content

Commit 274141c

Browse files
committed
feat(webapp): mark a chat unread when work finished behind a closed panel
A watch wake was the only thing that raised the dot and the highlight. An answer or a settled card that landed while the panel was closed now does the same — without a toast, which stays a wake's alone.
1 parent fa60e7d commit 274141c

9 files changed

Lines changed: 144 additions & 16 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@ export function DashboardAgent({
4444
promotedPrompt,
4545
/** From the page load: unread wakes waiting for this user, whatever this browser remembers. */
4646
initialUnreadWakes = 0,
47+
initialUnreadWork = 0,
4748
/** Also from the page load: a watch is running, so a wake can still arrive in this tab. */
4849
hasActiveWatches = false,
4950
}: {
5051
children: React.ReactNode;
5152
hasAccess?: boolean;
5253
promotedPrompt?: SuggestedPrompt;
5354
initialUnreadWakes?: number;
55+
/** Chats whose transcript moved on since their owner last looked. */
56+
initialUnreadWork?: number;
5457
hasActiveWatches?: boolean;
5558
}) {
5659
const organization = useOrganization();
@@ -61,6 +64,9 @@ export function DashboardAgent({
6164
const [open, setOpen] = useState(false);
6265
// Seeded from the page load, so the launcher dot is right before the first poll answers.
6366
const [unreadWakes, setUnreadWakes] = useState(initialUnreadWakes);
67+
// Work that finished behind a closed panel. Counted server-side on page load and refreshed
68+
// with the chat list; the wake poll doesn't carry it.
69+
const [unreadWork, setUnreadWork] = useState(initialUnreadWork);
6470
const toastedWakes = useRef(new Set<string>());
6571
// The toast source is recent deliveries, not unread, so the dedupe must survive a reload.
6672
useEffect(() => {
@@ -222,6 +228,8 @@ export function DashboardAgent({
222228
async (chatId: string) => {
223229
visibleChat.current = chatId;
224230
setUnreadWakes(0);
231+
// Opening a chat is what makes its work read, so the dot goes with it.
232+
setUnreadWork((count) => Math.max(0, count - 1));
225233
const body = new FormData();
226234
body.set("intent", "read");
227235
body.set("chatId", chatId);
@@ -258,8 +266,8 @@ export function DashboardAgent({
258266
useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });
259267

260268
const context = useMemo(
261-
() => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes }),
262-
[open, setPanelOpen, openWith, openWithWatch, unreadWakes]
269+
() => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes, unreadWork }),
270+
[open, setPanelOpen, openWith, openWithWatch, unreadWakes, unreadWork]
263271
);
264272

265273
if (!hasAccess) {
@@ -293,6 +301,7 @@ export function DashboardAgent({
293301
newChatSeq={newChatSeq}
294302
promotedPrompt={promotedPrompt}
295303
onChatRead={markChatRead}
304+
onUnreadWorkChange={setUnreadWork}
296305
isFullscreen={fullscreen}
297306
onToggleFullscreen={toggleFullscreen}
298307
/>

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type DashboardAgentChat = {
1616
lastMessageAt: string | null;
1717
watches?: WatchChip[];
1818
hasUnreadWake?: boolean;
19+
/** The chat answered, settled a card or woke while it was closed. */
20+
hasUnreadWork?: boolean;
1921
hasActiveWatch?: boolean;
2022
hasOpenInvestigation?: boolean;
2123
};
@@ -49,11 +51,14 @@ function ProcessIcon({ process }: { process: ChatProcess }) {
4951
);
5052
}
5153

54+
/** A wake is unread work too, so one predicate answers for both. */
55+
export function chatIsUnread(chat: DashboardAgentChat): boolean {
56+
return (chat.hasUnreadWake ?? false) || (chat.hasUnreadWork ?? false);
57+
}
58+
5259
// Must stay a stable sort on one key: everything else keeps the server's order.
5360
function unreadFirst(chats: DashboardAgentChat[]): DashboardAgentChat[] {
54-
return [...chats].sort(
55-
(a, b) => Number(b.hasUnreadWake ?? false) - Number(a.hasUnreadWake ?? false)
56-
);
61+
return [...chats].sort((a, b) => Number(chatIsUnread(b)) - Number(chatIsUnread(a)));
5762
}
5863

5964
// Weeks are the coarsest unit: months render as "1.8mo" for eight weeks.
@@ -104,7 +109,7 @@ export function DashboardAgentHistoryMenu({
104109
<AgentListRow
105110
key={chat.id}
106111
label={chat.title}
107-
unread={chat.hasUnreadWake ?? false}
112+
unread={chatIsUnread(chat)}
108113
status={process ? <ProcessIcon process={process} /> : null}
109114
meta={age}
110115
variant={chat.id === currentChatId ? "selected" : "default"}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function DashboardAgentPanel({
7171
promotedPrompt,
7272
watchRequest,
7373
onChatRead,
74+
onUnreadWorkChange,
7475
isFullscreen = false,
7576
onToggleFullscreen,
7677
}: {
@@ -84,6 +85,8 @@ export function DashboardAgentPanel({
8485
promotedPrompt?: SuggestedPrompt;
8586
watchRequest?: { spec: WatchSpec; seq: number };
8687
onChatRead?: (chatId: string) => void;
88+
/** How many chats still hold work their owner hasn't seen. */
89+
onUnreadWorkChange?: (count: number) => void;
8790
}) {
8891
const organization = useOrganization();
8992
const project = useProject();
@@ -153,9 +156,12 @@ export function DashboardAgentPanel({
153156
const pending = chats.some((chat) => chat.hasActiveWatch || chat.hasUnreadWake);
154157
if (pending) rememberWatchActivity(organization.id);
155158
else forgetWatchActivity(organization.id);
156-
setChats(
157-
chats.map((chat) => (read.has(chat.id) ? { ...chat, hasUnreadWake: false } : chat))
159+
const settled = chats.map((chat) =>
160+
read.has(chat.id) ? { ...chat, hasUnreadWake: false, hasUnreadWork: false } : chat
158161
);
162+
setChats(settled);
163+
// The launcher's dot is server-counted on page load; this keeps it honest between loads.
164+
onUnreadWorkChange?.(settled.filter((chat) => chat.hasUnreadWork).length);
159165
} catch (error) {
160166
console.error("Dashboard agent: failed to load chat history", error);
161167
toast.error("We couldn't load your previous chats. Try again in a moment.");

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ type DashboardAgentContextValue = {
3131
openWithWatch: (spec: WatchSpec) => void;
3232
/** Polled only while the panel is closed; 0 while it is open. */
3333
unreadWakes: number;
34+
/** Chats that answered, settled or woke while the panel was closed. */
35+
unreadWork: number;
3436
};
3537

3638
const DashboardAgentContext = createContext<DashboardAgentContextValue | null>(null);
@@ -48,12 +50,12 @@ export function DashboardAgentLauncher() {
4850
return null;
4951
}
5052

51-
const { open, setOpen, unreadWakes } = agent;
53+
const { open, setOpen, unreadWakes, unreadWork } = agent;
5254
if (open) {
5355
return null;
5456
}
5557

56-
const hasUnread = unreadWakes > 0;
58+
const hasUnread = unreadWakes > 0 || unreadWork > 0;
5759

5860
return (
5961
<SimpleTooltip
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
import { chatIsUnread } from "./DashboardAgentHistory";
3+
4+
/**
5+
* A chat is unread when its transcript moved on after its owner last looked — whether that
6+
* was a watch waking it or an answer that landed while the panel was closed. Both raise the
7+
* dot and the highlight; only a wake also raises a toast.
8+
*/
9+
describe("chatIsUnread", () => {
10+
const chat = (over: Record<string, unknown> = {}) =>
11+
({ id: "chat_1", title: "t", lastMessageAt: null, ...over }) as never;
12+
13+
it("counts work that finished behind a closed panel", () => {
14+
expect(chatIsUnread(chat({ hasUnreadWork: true }))).toBe(true);
15+
});
16+
17+
it("still counts a watch wake", () => {
18+
expect(chatIsUnread(chat({ hasUnreadWake: true }))).toBe(true);
19+
});
20+
21+
it("leaves a chat its owner has seen", () => {
22+
expect(chatIsUnread(chat())).toBe(false);
23+
expect(chatIsUnread(chat({ hasUnreadWake: false, hasUnreadWork: false }))).toBe(false);
24+
});
25+
});

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
countChatsWithUnreadWork,
23
readDashboardAgentWakeActivity,
34
type DashboardAgentWakeActivity,
45
} from "@internal/dashboard-agent-db";
@@ -107,12 +108,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
107108
unreadWakes: 0,
108109
hasActiveWatches: false,
109110
};
111+
let dashboardAgentUnreadWork = 0;
110112
if (hasDashboardAgentAccess) {
111113
try {
112-
dashboardAgentActivity = await readDashboardAgentWakeActivity(dashboardAgentDb, {
113-
organizationId: project.organization.id,
114-
userId: user.id,
115-
});
114+
[dashboardAgentActivity, dashboardAgentUnreadWork] = await Promise.all([
115+
readDashboardAgentWakeActivity(dashboardAgentDb, {
116+
organizationId: project.organization.id,
117+
userId: user.id,
118+
}),
119+
countChatsWithUnreadWork(dashboardAgentDb, {
120+
organizationId: project.organization.id,
121+
userId: user.id,
122+
}),
123+
]);
116124
} catch (error) {
117125
// The dashboard must load even when the agent's store doesn't answer.
118126
logger.error("Failed to read dashboard agent wake activity", { error });
@@ -124,17 +132,23 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
124132
hasDashboardAgentAccess,
125133
promotedDashboardAgentPrompt,
126134
dashboardAgentActivity,
135+
dashboardAgentUnreadWork,
127136
};
128137
};
129138

130139
export default function Page() {
131-
const { hasDashboardAgentAccess, promotedDashboardAgentPrompt, dashboardAgentActivity } =
132-
useLoaderData<typeof loader>();
140+
const {
141+
hasDashboardAgentAccess,
142+
promotedDashboardAgentPrompt,
143+
dashboardAgentActivity,
144+
dashboardAgentUnreadWork,
145+
} = useLoaderData<typeof loader>();
133146
return (
134147
<DashboardAgent
135148
hasAccess={hasDashboardAgentAccess}
136149
promotedPrompt={promotedDashboardAgentPrompt ?? undefined}
137150
initialUnreadWakes={dashboardAgentActivity.unreadWakes}
151+
initialUnreadWork={dashboardAgentUnreadWork}
138152
hasActiveWatches={dashboardAgentActivity.hasActiveWatches}
139153
>
140154
<Outlet />

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
197197
...chat,
198198
watches,
199199
hasUnreadWake: unreadChatIds.has(chat.id),
200+
// Work that finished while the chat was closed: the transcript moved on after the
201+
// last time its owner looked. A wake is one way that happens, an answer is another.
202+
hasUnreadWork:
203+
chat.lastMessageAt !== null &&
204+
(chat.lastReadAt === null || chat.lastMessageAt > chat.lastReadAt),
200205
// `watches` also carries fired and expired rows, so check for active here.
201206
hasActiveWatch: watches.some((watch) => watch.status === "active"),
202207
hasOpenInvestigation: investigatingChatIds.has(chat.id),

apps/webapp/test/dashboardAgentTranscriptStore.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
appendChatMessageOnceByChatId,
3+
countChatsWithUnreadWork,
34
countUserMessages,
45
createChat,
56
createDashboardAgentDb,
@@ -659,6 +660,40 @@ describe("a write can no longer lose a message another process appended", () =>
659660
);
660661
});
661662

663+
describe("countChatsWithUnreadWork", () => {
664+
postgresTest(
665+
"counts a chat whose transcript moved on after its owner last looked",
666+
async ({ prisma, postgresContainer }) => {
667+
const chatId = "chat_unread_work";
668+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
669+
const scope = { organizationId: ORG_ID, userId: USER_ID };
670+
671+
// A chat nobody has written in is not unread.
672+
expect(await countChatsWithUnreadWork(agentDb, scope)).toBe(0);
673+
674+
await persistMessages(agentDb, { chatId, messages: [textMessage("a1")] });
675+
expect(await countChatsWithUnreadWork(agentDb, scope)).toBe(1);
676+
677+
// Opening it clears the state...
678+
await prisma.$executeRawUnsafe(
679+
`update trigger_dashboard_agent.chats set last_read_at = now() where id = $1`,
680+
chatId
681+
);
682+
expect(await countChatsWithUnreadWork(agentDb, scope)).toBe(0);
683+
684+
// ...until the next answer lands behind a closed panel.
685+
await persistMessages(agentDb, { chatId, messages: [textMessage("a2")] });
686+
expect(await countChatsWithUnreadWork(agentDb, scope)).toBe(1);
687+
688+
// Another user's chat is never counted here.
689+
expect(
690+
await countChatsWithUnreadWork(agentDb, { ...scope, userId: "user_someone_else" })
691+
).toBe(0);
692+
},
693+
30_000
694+
);
695+
});
696+
662697
describe("countUserMessages", () => {
663698
postgresTest(
664699
"counts a user's own messages, and only those",

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface ChatListItem {
3434
title: string;
3535
pinnedAt: Date | null;
3636
lastMessageAt: Date | null;
37+
/** When the owner last had this chat open. Older than `lastMessageAt` means unread. */
38+
lastReadAt: Date | null;
3739
createdAt: Date;
3840
updatedAt: Date;
3941
metadata: Record<string, unknown>;
@@ -50,6 +52,7 @@ export async function listChats(
5052
title: chats.title,
5153
pinnedAt: chats.pinnedAt,
5254
lastMessageAt: chats.lastMessageAt,
55+
lastReadAt: chats.lastReadAt,
5356
createdAt: chats.createdAt,
5457
updatedAt: chats.updatedAt,
5558
metadata: chats.metadata,
@@ -117,6 +120,30 @@ export async function countUserMessages(
117120
return rows[0]?.count ?? 0;
118121
}
119122

123+
/**
124+
* Chats whose transcript moved on after their owner last looked. A watch wake is one way
125+
* that happens; an answer that landed while the panel was closed is another, and the panel
126+
* shows them the same way — a dot on the launcher, the chat lifted and highlighted.
127+
*/
128+
export async function countChatsWithUnreadWork(
129+
db: DashboardAgentDb,
130+
params: { organizationId: string; userId: string }
131+
): Promise<number> {
132+
const rows = await db
133+
.select({ count: sql<number>`count(*)::int` })
134+
.from(chats)
135+
.where(
136+
and(
137+
eq(chats.organizationId, params.organizationId),
138+
eq(chats.userId, params.userId),
139+
isNull(chats.deletedAt),
140+
sql`${chats.lastMessageAt} is not null`,
141+
sql`(${chats.lastReadAt} is null or ${chats.lastMessageAt} > ${chats.lastReadAt})`
142+
)
143+
);
144+
return rows[0]?.count ?? 0;
145+
}
146+
120147
/** Joins `chats` to scope by owner, because `chat_sessions` has no `userId`. */
121148
export async function getSession(
122149
db: DashboardAgentDb,

0 commit comments

Comments
 (0)