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>
233 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|