Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/web/src/chat/avatar.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -125,3 +127,30 @@ export function CorbitAvatar({
</span>
);
}

/** 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 (
<span aria-hidden="true">
<CorbitAvatar size="sm" ariaLabel={name} />
</span>
);
}
return (
<span className={`shell-ch-initial ${avatarClassForPrincipal(principalId)}`} aria-hidden="true">
{agentInitials(name)}
</span>
);
}
40 changes: 40 additions & 0 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -411,11 +417,45 @@ export async function listChats(tenantId: string): Promise<readonly ChatSummary[
agentName,
preview: newest.body.slice(0, 80),
lastActivityAt: newest.at,
lastAuthor: newest.author,
lastMessageId: newest.messageId,
};
})
.sort((a, b) => 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<ChatSummary, "id" | "lastAuthor" | "lastMessageId">,
): 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<ChatThread> {
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -171,9 +178,11 @@ function ChatTranscript({
{chat.messages.map((message) => (
<div key={message.id} className="chat-thread-message" data-author={message.author}>
<span className="shell-ch-avatar">
<span className="shell-ch-initial" aria-hidden="true">
{message.author === "me" ? "You" : agentInitials(message.authorName)}
</span>
<IdentityAvatar
kind={message.author === "me" ? "person" : "agent"}
name={message.authorName}
principalId={message.author === "me" ? (selectedPrincipalId ?? "me") : chat.id}
/>
</span>
<div className="chat-thread-body">
<Markdown text={message.body} />
Expand Down
20 changes: 13 additions & 7 deletions apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="chat-thread-message" data-author={message.author}>
<span className="shell-ch-avatar">
<span className="shell-ch-initial" aria-hidden="true">
{message.author === "me" ? "You" : agentInitials(displayName)}
</span>
<IdentityAvatar
kind={kind}
name={displayName}
principalId={matched?.id ?? message.address}
/>
</span>
<div className="chat-thread-body">
<Markdown text={message.body} />
Expand All @@ -83,9 +87,11 @@ function ParticipantList({ participants }: { readonly participants: readonly Roo
{participants.map((participant) => (
<li key={participant.id} data-kind={participant.kind}>
<span className="shell-ch-avatar">
<span className="shell-ch-initial" aria-hidden="true">
{agentInitials(participant.name)}
</span>
<IdentityAvatar
kind={participant.kind}
name={participant.name}
principalId={participant.id}
/>
</span>
{participant.name}
</li>
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/shell/workbench-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<button
type="button"
className="shell-ch-row"
aria-current={active ? "true" : undefined}
data-active={active ? "true" : undefined}
data-unread={replyReady ? "true" : undefined}
onClick={onSelect}
>
<span className="shell-ch-avatar">
<span className="shell-ch-initial" aria-hidden="true">
{agentInitials(chat.agentName)}
<span className="shell-ch-avatar" data-live={replyReady ? "reply-ready" : undefined}>
<IdentityAvatar kind="agent" name={chat.agentName} principalId={chat.id} />
{replyReady ? (
<span className="shell-ch-completion" aria-hidden="true" title="Reply ready">
<Check aria-hidden="true" />
</span>
) : null}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{replyReady ? "Reply ready" : ""}
</span>
</span>
<span className="shell-ch-meta">
Expand Down
Loading