Skip to content

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 ​

WireWithout it
actions on the listApproval 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 questionA free-text question can only be answered on its card
resolveFileUrlPictures of a loaded conversation do not show unless the file has its own url
onLoadEarlierOnly the newest page of a long conversation is reachable
interrupted / onRetryInterruptedA reply cut off by a restart leaves the person's message without any explanation
BudgetBannerNo 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.

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub