diff --git a/apps/web/src/chat/avatar.tsx b/apps/web/src/chat/avatar.tsx
index 1ccf6b2c80..e7beb4af5f 100644
--- a/apps/web/src/chat/avatar.tsx
+++ b/apps/web/src/chat/avatar.tsx
@@ -1,5 +1,7 @@
import type { CSSProperties } from "react";
+import { agentInitials } from "@/chat/threads-api";
+
/* Avatar identity pastels. The palette is token references, not
hex: --avatar-1 through --avatar-4 are defined once in this package's
stylesheet :root (the proposed upstream contract for @corbits/react-ui's
@@ -125,3 +127,30 @@ export function CorbitAvatar({
);
}
+
+/** The one avatar element for a `.shell-ch-avatar` slot: a Corbit avatar for
+ * an agent, a pastel initials chip (hashed off the person's own principal
+ * id, never their name) for a person — visually distinct per DESIGN.md's
+ * avatar identity rule. */
+export function IdentityAvatar({
+ kind,
+ name,
+ principalId,
+}: {
+ readonly kind: "agent" | "person";
+ readonly name: string;
+ readonly principalId: string;
+}) {
+ if (kind === "agent") {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {agentInitials(name)}
+
+ );
+}
diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts
index 8e065a488b..4cbe0defca 100644
--- a/apps/web/src/chat/threads-api.ts
+++ b/apps/web/src/chat/threads-api.ts
@@ -14,6 +14,7 @@
import { type } from "arktype";
import { WorkflowDeploymentResponse } from "@intx/types";
+import { reportError } from "@corbits/error-sink";
import { listTopLevelRuns } from "../agents-api";
import { MYRA_SOURCE_CONFIG } from "../myra-source";
@@ -63,6 +64,11 @@ export type ChatSummary = {
readonly agentName: string;
readonly preview: string;
readonly lastActivityAt: string;
+ /** Who sent the newest turn — an "agent" turn is an unread reply until
+ * the chat is opened. */
+ readonly lastAuthor: "me" | "agent";
+ /** The newest turn's Message-ID, compared against what was last seen. */
+ readonly lastMessageId: string;
};
/** Myra is the one default agent; her deploy asset name is not a display
@@ -411,11 +417,45 @@ export async function listChats(tenantId: string): Promise Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt));
}
+// ---------------------------------------------------------------------
+// Reply-ready: a chat is unread until it has been opened at its newest
+// message. Tracked per-viewer in localStorage — a convenience, not data of
+// record, so a missing/blocked store just falls back to "ready".
+// ---------------------------------------------------------------------
+
+const CHAT_SEEN_KEY_PREFIX = "workbench:chat-seen:";
+
+/** Marks a chat as read up to its newest turn. Call when a chat is opened. */
+export function markChatSeen(chatId: string, lastMessageId: string | undefined): void {
+ if (lastMessageId === undefined) return;
+ try {
+ localStorage.setItem(`${CHAT_SEEN_KEY_PREFIX}${chatId}`, lastMessageId);
+ } catch (cause) {
+ reportError(cause, { operation: "chat_mark_seen", refId: chatId });
+ }
+}
+
+/** True when the chat's newest turn is an agent reply that has not yet been
+ * seen in this browser. */
+export function isChatReplyReady(
+ summary: Pick,
+): boolean {
+ if (summary.lastAuthor !== "agent") return false;
+ try {
+ return localStorage.getItem(`${CHAT_SEEN_KEY_PREFIX}${summary.id}`) !== summary.lastMessageId;
+ } catch (cause) {
+ reportError(cause, { operation: "chat_reply_ready_check", refId: summary.id });
+ return true;
+ }
+}
+
/** One chat's full transcript: every mail turn addressed to any run this
* agent has ever had, oldest first. */
export async function readChat(tenantId: string, chatId: string): Promise {
diff --git a/apps/web/src/pages/chat-thread-page.tsx b/apps/web/src/pages/chat-thread-page.tsx
index 4323a516a8..6b5a3f4879 100644
--- a/apps/web/src/pages/chat-thread-page.tsx
+++ b/apps/web/src/pages/chat-thread-page.tsx
@@ -7,12 +7,13 @@ import { WarningCircle } from "@/lib/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
+import { IdentityAvatar } from "@/chat/avatar";
import { Composer } from "@/chat/composer";
import { Markdown } from "@/chat/markdown";
import {
agentFromMention,
- agentInitials,
listChatAgents,
+ markChatSeen,
readChat,
replyInChat,
startChat,
@@ -127,6 +128,12 @@ function ChatTranscript({
refetchInterval: (query) => (query.state.data?.agent.liveAddress === null ? 3000 : false),
});
const chat = chatQuery.data;
+ const { selectedPrincipalId } = useBench();
+
+ // Opening the chat clears its reply-ready state — a render-time write
+ // (not an effect) since it's an idempotent mirror of already-fetched
+ // data, not a fetch of its own.
+ if (chat !== undefined) markChatSeen(chatId, chat.lastMessageId);
// The inbox stream is the only signal that an agent has answered; it
// carries no chat identity, so it invalidates rather than patches.
@@ -171,9 +178,11 @@ function ChatTranscript({
{chat.messages.map((message) => (