feat(assistant): add approval inbox and e2e hardening

This commit is contained in:
2026-03-29 10:10:59 +02:00
parent 4f48afe7b4
commit beae1a5d6e
12 changed files with 2482 additions and 331 deletions
+221 -21
View File
@@ -39,6 +39,17 @@ interface Message {
role: "user" | "assistant";
content: string;
insights?: AssistantInsight[];
approval?: AssistantApproval;
}
interface AssistantApproval {
id: string;
status: "pending" | "approved" | "cancelled";
conversationId: string;
toolName: string;
summary: string;
createdAt: string;
expiresAt: string;
}
interface AssistantInsightMetric {
@@ -61,6 +72,19 @@ interface AssistantInsight {
}
const STORAGE_KEY = "capakraken-chat-messages";
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
function isAssistantApproval(value: unknown): value is AssistantApproval {
if (!value || typeof value !== "object") return false;
const approval = value as Partial<AssistantApproval>;
return typeof approval.id === "string"
&& (approval.status === "pending" || approval.status === "approved" || approval.status === "cancelled")
&& typeof approval.conversationId === "string"
&& typeof approval.toolName === "string"
&& typeof approval.summary === "string"
&& typeof approval.createdAt === "string"
&& typeof approval.expiresAt === "string";
}
/** Load messages from sessionStorage (survives page reloads, clears on tab close). */
function loadPersistedMessages(): Message[] {
@@ -81,6 +105,7 @@ function loadPersistedMessages(): Message[] {
role: item.role,
content: item.content,
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}),
}));
}
}
@@ -91,17 +116,47 @@ function loadPersistedMessages(): Message[] {
/** Module-level cache — avoids re-parsing sessionStorage on every client-side navigation. */
let cachedMessages: Message[] | null = null;
function generateConversationId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `conversation-${Date.now()}`;
}
function loadConversationId(): string {
if (typeof window === "undefined") return generateConversationId();
try {
const raw = sessionStorage.getItem(CONVERSATION_ID_KEY)?.trim();
if (raw) return raw;
} catch {
// ignore storage access errors
}
return generateConversationId();
}
export function ChatPanel({ onClose }: { onClose: () => void }) {
const pathname = usePathname();
const router = useRouter();
const utils = trpc.useUtils();
const [messages, setMessages] = useState<Message[]>(() => cachedMessages ?? loadPersistedMessages());
const [conversationId, setConversationId] = useState<string>(() => loadConversationId());
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [approvalNotice, setApprovalNotice] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const chatMutation = trpc.assistant.chat.useMutation();
const pendingApprovalsQuery = trpc.assistant.listPendingApprovals.useQuery(undefined, {
refetchInterval: 30_000,
});
const pendingApprovals = pendingApprovalsQuery.data ?? [];
const inlineApprovalIds = new Set(
messages
.map((message) => message.approval?.id)
.filter((approvalId): approvalId is string => typeof approvalId === "string"),
);
const visiblePendingApprovals = pendingApprovals.filter((approval) => !inlineApprovalIds.has(approval.id));
// Sync to module-level cache + sessionStorage on every change
useEffect(() => {
@@ -109,6 +164,10 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ }
}, [messages]);
useEffect(() => {
try { sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId); } catch { /* quota exceeded */ }
}, [conversationId]);
// Auto-scroll to bottom on new messages
useEffect(() => {
const el = scrollRef.current;
@@ -120,37 +179,58 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
inputRef.current?.focus();
}, []);
const sendMessage = useCallback(async () => {
const text = input.trim();
const sendMessage = useCallback(async (
overrideText?: string,
targetConversationId?: string,
options?: { persistInCurrentChat?: boolean },
) => {
const text = (overrideText ?? input).trim();
if (!text || isLoading) return;
const persistInCurrentChat = options?.persistInCurrentChat ?? true;
const activeConversationId = targetConversationId ?? conversationId;
setInput("");
if (!overrideText) {
setInput("");
}
setError(null);
setApprovalNotice(null);
const userMsg: Message = { role: "user", content: text };
const updated = [...messages, userMsg];
setMessages(updated);
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
if (persistInCurrentChat) {
setMessages(updated);
}
setIsLoading(true);
try {
const reply = await chatMutation.mutateAsync({
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({
role: message.role,
content: message.content,
})),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
conversationId: activeConversationId,
});
const typedReply = reply as {
content: string;
role: "assistant";
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
insights?: AssistantInsight[];
approval?: AssistantApproval;
};
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
},
]);
if (persistInCurrentChat) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
...(isAssistantApproval(typedReply.approval) ? { approval: typedReply.approval } : {}),
},
]);
} else {
setApprovalNotice(typedReply.content);
}
// Handle actions from the AI (navigation, data invalidation)
const actions = typedReply.actions;
@@ -172,13 +252,14 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
}
}
}
await utils.assistant.listPendingApprovals.invalidate();
} catch (err) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages, chatMutation, pathname]);
}, [conversationId, input, isLoading, messages, chatMutation, pathname, router, utils]);
// Track user message history for up-arrow recall
const userHistory = useRef<string[]>([]);
@@ -221,6 +302,8 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
const clearChat = () => {
setMessages([]);
setError(null);
setApprovalNotice(null);
setConversationId(generateConversationId());
cachedMessages = null;
try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
};
@@ -269,6 +352,79 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
{(visiblePendingApprovals.length > 0 || approvalNotice) && (
<div
data-testid="assistant-open-approvals"
className="space-y-2 rounded-2xl border border-amber-200 bg-amber-50/80 p-3 text-xs text-amber-950 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-50"
>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-amber-800 dark:text-amber-200">
Open approvals
</div>
{visiblePendingApprovals.length > 0 && (
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[11px] font-medium dark:border-amber-800">
{visiblePendingApprovals.length}
</span>
)}
</div>
{approvalNotice && (
<div className="rounded-xl border border-amber-300/80 bg-white/70 px-3 py-2 text-[12px] text-amber-900 dark:border-amber-800 dark:bg-slate-900/40 dark:text-amber-100">
{approvalNotice}
</div>
)}
{visiblePendingApprovals.map((approval) => {
const belongsToCurrentConversation = approval.conversationId === conversationId;
return (
<div
key={approval.id}
data-testid="assistant-approval-card"
data-approval-id={approval.id}
data-conversation-scope={belongsToCurrentConversation ? "current" : "other"}
className="rounded-xl border border-amber-200/90 bg-white/80 px-3 py-2.5 shadow-sm dark:border-amber-900/50 dark:bg-slate-900/40"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-amber-950 dark:text-amber-50">
{approval.summary}
</div>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
<span>{approval.toolName}</span>
<span>Expires {new Date(approval.expiresAt).toLocaleString()}</span>
</div>
</div>
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] dark:border-amber-800">
{belongsToCurrentConversation ? "This chat" : "Other chat"}
</span>
</div>
<div className="mt-2 flex gap-2">
<button
type="button"
data-testid="assistant-approval-confirm"
onClick={() => void sendMessage("Ja, bitte ausführen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})}
disabled={isLoading}
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
>
Confirm
</button>
<button
type="button"
data-testid="assistant-approval-cancel"
onClick={() => void sendMessage("Abbrechen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})}
disabled={isLoading}
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
>
Cancel
</button>
</div>
</div>
);
})}
</div>
)}
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-12 text-center text-sm text-gray-400 dark:text-gray-500">
<svg className="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -279,12 +435,56 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
</div>
)}
{messages.map((msg, i) => (
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
{...(msg.insights ? { insights: msg.insights } : {})}
/>
<div key={i} className="space-y-2">
<ChatMessage
role={msg.role}
content={msg.content}
{...(msg.insights ? { insights: msg.insights } : {})}
/>
{msg.role === "assistant" && msg.approval && (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl border border-amber-200 bg-amber-50/90 px-3 py-2.5 text-xs text-amber-900 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold uppercase tracking-[0.08em]">
{msg.approval.status === "pending" ? "Approval pending" : msg.approval.status}
</span>
<span className="rounded-full border border-amber-300/70 px-2 py-0.5 font-medium dark:border-amber-800">
{msg.approval.toolName}
</span>
</div>
<div className="mt-1.5 text-sm font-medium text-amber-950 dark:text-amber-50">
{msg.approval.summary}
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
<span>Created: {new Date(msg.approval.createdAt).toLocaleString()}</span>
{msg.approval.status === "pending" && (
<span>Expires: {new Date(msg.approval.expiresAt).toLocaleString()}</span>
)}
</div>
{msg.approval.status === "pending" && (
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => void sendMessage("Ja, bitte ausführen.")}
disabled={isLoading}
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
>
Confirm
</button>
<button
type="button"
onClick={() => void sendMessage("Abbrechen.")}
disabled={isLoading}
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
>
Cancel
</button>
</div>
)}
</div>
</div>
)}
</div>
))}
{isLoading && <TypingIndicator />}
{error && (