Approvals in a Custom Layout
Build the chat from useChat, ChatMessageList and ChatInput and wire everything a paused agent needs: approval and question cards, the composer state, the usage limit, files and the interrupted-reply notice.
tsx
import { useChat, ChatMessageList, ChatInput, BudgetBanner } from "@cognipeer/chat-ui";
import "@cognipeer/chat-ui/styles.css";
export default function OpsChat() {
const chat = useChat({
baseUrl: "https://agents.example.com",
agentId: "ops",
pendingActionComposerMode: "block", // or "cancel": a new message cancels the pending request
fileTransport: "upload-on-select",
onActionRequired: ({ action }) => console.log("waiting:", action.type),
onActionResolved: ({ actionId, resolution }) => audit(actionId, resolution),
onBudgetReached: ({ conversationId, spentUsd }) => notify(conversationId, spentUsd),
});
// A lone free-text question is answered from the composer
const send = (text: string) =>
chat.composerState === "answer" && chat.answerableQuestion
? chat.resolveAction(chat.answerableQuestion.actionId, {
type: "user_question",
answers: { [chat.answerableQuestion.question.question]: text },
})
: chat.sendMessage(text); // resolves false when refused: the composer then gives the text back
return (
<div className="flex h-screen flex-col">
<ChatMessageList
messages={chat.messages}
isStreaming={chat.isStreaming}
streamingText={chat.streamingText}
progressMessage={chat.progressMessage}
activeToolCalls={chat.activeToolCalls}
// cards: without `actions` they are read-only
actions={{
onResolve: chat.resolveAction,
canResolve: chat.canResolveActions,
allowArgumentEdit: true,
expiryWarningSeconds: 300,
}}
resolveFileUrl={chat.resolveFileUrl} // pictures and downloads of a loaded conversation
hasEarlier={chat.hasEarlierMessages}
isLoadingEarlier={chat.isLoadingEarlierMessages}
onLoadEarlier={chat.loadEarlierMessages}
interrupted={chat.interrupted} // "The reply was interrupted"
onRetryInterrupted={chat.retryInterrupted}
/>
{(chat.budget || chat.budgetExhausted) && (
<div className="mx-auto w-full max-w-3xl px-4">
<BudgetBanner
budget={chat.budget}
spentUsd={chat.spentUsd}
exhausted={chat.budgetExhausted}
canContinue={chat.canContinue}
canEdit={chat.canUpdateBudget}
busy={chat.isStreaming}
onContinue={() => chat.continueConversation()}
onUpdate={(budget, options) => chat.updateBudget(budget, options)}
/>
</div>
)}
{chat.error && <p role="alert">{chat.error.message}</p>}
<ChatInput
onSend={send}
onStop={chat.stop}
isLoading={chat.isStreaming}
isUploading={chat.isUploading}
pendingFiles={chat.pendingFiles}
onFilesAdd={chat.addFiles}
onFileRemove={chat.removeFile}
onFileRetry={chat.retryFile}
state={chat.composerState} // open | blocked | cancel | answer | following | budget_paused | readonly
/>
</div>
);
}What each wire does
| Wire | Without it |
|---|---|
actions on the list | Approval and question cards are read-only ("Waiting for approval in another app") |
state={chat.composerState} | The composer stays open while a card waits or the usage limit is used up |
send that answers a lone question | A free-text question can only be answered on its card |
resolveFileUrl | Pictures of a loaded conversation do not show unless the file has its own url |
onLoadEarlier | Only the newest page of a long conversation is reachable |
interrupted / onRetryInterrupted | A reply cut off by a restart leaves the person's message without any explanation |
BudgetBanner | No meter and no way to raise the limit |
Your own card
tsx
<ChatMessageList
messages={chat.messages}
actions={{
onResolve: chat.resolveAction,
canResolve: chat.canResolveActions,
renderToolApproval: ({ action, state, readOnly, resolve }) => (
<MyApproval
title={action.title ?? action.toolName}
args={action.args}
busy={state?.status === "resolving"}
disabled={readOnly || state?.status !== "pending"}
onAllow={() => resolve({ type: "tool_approval", decision: "allow", rememberForSession: true })}
onDeny={(reason) => resolve({ type: "tool_approval", decision: "deny", comment: reason })}
/>
),
}}
/>Your card is responsible for its own accessibility: see Accessibility.

