From 2faecdda6e1aa99b8b8359022ebf1e8a6d590bcb Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 00:06:16 -0700 Subject: [PATCH] feat(web): Corbit avatars in sidebar and transcript with live-turn and reply-ready indicators (CL-8475) Wire CorbitAvatar/IdentityAvatar into the sidebar chat rows, chat transcript, and room timeline/participant list, and restore the sidebar reply-ready indicator (unread agent reply, cleared on open) using existing chat-summary data. A turn-in-flight signal isn't derivable from the mailbox data available on the client, so only reply-ready is implemented. --- apps/web/src/chat/avatar.tsx | 29 ++++++++++++++++ apps/web/src/chat/threads-api.ts | 40 ++++++++++++++++++++++ apps/web/src/pages/chat-thread-page.tsx | 17 ++++++--- apps/web/src/pages/workbench-room-page.tsx | 20 +++++++---- apps/web/src/shell/workbench-list.tsx | 21 +++++++++--- 5 files changed, 111 insertions(+), 16 deletions(-) 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 ( + + ); +} 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) => (
- +
diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index 2d42a7b49d..c5d8345555 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -18,10 +18,10 @@ import { WarningCircle } from "@/lib/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; +import { IdentityAvatar } from "@/chat/avatar"; import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; import { - agentInitials, ancestorChain, listRoomParticipants, readRoom, @@ -58,12 +58,16 @@ function RoomMessageRow({ // Avatars read off the participant's display name (Myra → "M"), never the // run address local part a mail turn otherwise carries. const displayName = resolveParticipantName(message, participants); + const matched = participants.find((participant) => participant.address === message.address); + const kind = message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; return (
- +
@@ -83,9 +87,11 @@ function ParticipantList({ participants }: { readonly participants: readonly Roo {participants.map((participant) => (
  • - + {participant.name}
  • diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index 47636c9a46..70e3c8468c 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -4,10 +4,11 @@ // membership is a workbench concept; a chat has exactly one agent. import { EmptyState, Input, Skeleton } from "@corbits/react-ui"; -import { Hash, MagnifyingGlass } from "@/lib/icons"; +import { Check, Hash, MagnifyingGlass } from "@/lib/icons"; import { useState } from "react"; -import { agentInitials, type ChatSummary } from "@/chat/threads-api"; +import { IdentityAvatar } from "@/chat/avatar"; +import { isChatReplyReady, type ChatSummary } from "@/chat/threads-api"; import { useBench } from "../bench-context"; import { chatPath, CHATS_PATH_PREFIX } from "../chat-path"; @@ -68,17 +69,27 @@ function ChatRow({ readonly active: boolean; readonly onSelect: () => void; }) { + // A chat is reply-ready once its newest turn is an unseen agent reply; + // opening the chat (readChat/markChatSeen) clears it. + const replyReady = !active && isChatReplyReady(chat); return (