evOo-local/apps/web/src/components/MainInbox.tsx
cynarAI 6fd136e944
feat: suggestions the user accepts or dismisses with one tap (#142)
Bots can offer an exact tool call as a card. Nothing runs until the user
taps Accept; the call then runs as its own approved run of the bot it was
meant for, and the card shows the outcome. Open suggestions also wait in
the inbox and expire after a week.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 02:19:31 +02:00

233 lines
7.9 KiB
TypeScript

import { useLingui } from "@lingui/react/macro";
import type { Bot, CardAct, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
import { resolveOoColor } from "@rakazo/core";
import { BotAvatar, parseBotAvatar } from "@rakazo/ui-web";
import { Monitor } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { inboxItemKey, inboxRefreshSignal } from "../lib/main-inbox";
import { rpc } from "../lib/rpc";
import { newClientNonce } from "../pages/shell/thread-state";
import { AskCard } from "./AskCard";
import { ThreadCard } from "./ThreadCard";
/** Catches what the bots' state does not reveal, such as a second ask with the same text. */
const INBOX_POLL_MS = 15_000;
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
type SuggestionItem = Extract<InboxItem, { kind: "suggestion" }>;
/** Thread procedures for an item run in the item's own space. */
function itemContext(item: InboxItem) {
return { context: item.space ? { spaceId: item.space.id } : {} };
}
/**
* What the user's bots in all of their spaces need from them outside the main conversation, with
* each bot as the sender: asks, approvals and suggestions are answered in place, takeovers and unread threads
* open the bot's thread.
*/
export function MainInbox({
bots,
onOpenBot,
onOpenGroup,
onOpenInSpace,
}: {
/** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[];
onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void;
/** Opens an app path, such as a thread, in another of the user's spaces. */
onOpenInSpace: (spaceId: string, path: string) => void;
}) {
const { t } = useLingui();
const [items, setItems] = useState<InboxItem[]>([]);
const latestRequest = useRef(0);
const signal = useMemo(() => inboxRefreshSignal(bots), [bots]);
const refresh = useCallback(async () => {
const request = ++latestRequest.current;
try {
const listed = await rpc.inbox.list();
if (request === latestRequest.current) setItems(listed.items);
} catch {
// Keep the last list; the next refresh retries.
}
}, []);
// `signal` is the trigger: refetch whenever the bots' state changes.
useEffect(() => {
void refresh();
}, [refresh, signal]);
useEffect(() => {
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void refresh();
}, INBOX_POLL_MS);
return () => {
window.clearInterval(timer);
// Ignore responses that arrive after unmount.
latestRequest.current += 1;
};
}, [refresh]);
const open = useCallback(
({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => {
if (space) {
onOpenInSpace(
space.id,
"botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`,
);
} else if ("botId" in target) onOpenBot(target.botId);
else onOpenGroup(target.groupId);
},
[onOpenBot, onOpenGroup, onOpenInSpace],
);
const answer = useCallback(
async (item: PendingAskItem, text: string) => {
try {
// Answering reads the ask, so the bot's thread should not come back as unread with the
// same question. Mark it first: the reply to this answer marks it unread again.
if ("botId" in item.target) {
await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined);
}
await rpc.threads.answer(
{ ...item.target, runId: item.runId, messageId: item.messageId, answer: text },
itemContext(item),
);
const key = inboxItemKey(item);
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
} finally {
void refresh();
}
},
[refresh],
);
const actOnSuggestion = useCallback(
async (item: SuggestionItem, act: CardAct) => {
const cardId = item.card.id;
if (!cardId) return;
try {
await rpc.threads.act(
{
...item.target,
messageId: item.messageId,
cardId,
act,
clientNonce: newClientNonce(),
},
itemContext(item),
);
} finally {
void refresh();
}
},
[refresh],
);
if (items.length === 0) return null;
return (
// Shrinks and scrolls before it can push the composer out of view.
<section
aria-label={t`Needs you`}
data-testid="main-inbox"
className="max-h-[40vh] min-h-0 overflow-y-auto px-3 md:px-6"
>
<ul className="mx-auto flex w-full max-w-4xl flex-col gap-3 border-t border-border pt-3">
{items.map((item) => (
<li key={inboxItemKey(item)} data-testid="main-inbox-item" data-kind={item.kind}>
{item.kind === "suggestion" ? (
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => open(item)}
className="w-fit rounded-md hover:underline"
>
<Sender bot={item.bot} space={item.space} />
</button>
<ThreadCard
block={item.card}
createdAt={item.createdAt}
onAct={(act) => actOnSuggestion(item, act)}
/>
</div>
) : item.kind === "ask" || item.kind === "approval" ? (
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => open(item)}
className="w-fit rounded-md hover:underline"
>
<Sender bot={item.bot} space={item.space} />
</button>
<AskCard
block={item.ask}
canAnswer
onAnswer={(text) => answer(item, text)}
className="max-w-none"
/>
</div>
) : (
<button
type="button"
onClick={() => open(item)}
className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent"
>
<span className="min-w-0 flex-1">
<Sender bot={item.bot} space={item.space} />
{item.text ? (
<span
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
dir="auto"
>
{item.text}
</span>
) : null}
</span>
{item.kind === "takeover" ? (
<Monitor
aria-hidden="true"
className="mt-0.5 size-icon-md shrink-0 text-warning"
/>
) : (
<span
aria-hidden="true"
className="mt-2 size-2 shrink-0 rounded-full bg-primary"
/>
)}
</button>
)}
</li>
))}
</ul>
</section>
);
}
/** The bot, and the space it works in when that is not the current one. */
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
const color = resolveOoColor(bot.id, parseBotAvatar(bot.color).color).light;
return (
<span
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
style={{ color }}
dir="auto"
>
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
<span className="truncate">{bot.name}</span>
{space ? (
<span
data-testid="main-inbox-space"
className="truncate font-normal text-muted-foreground"
dir="auto"
>
<span aria-hidden="true">· </span>
{space.name}
</span>
) : null}
</span>
);
}