# Optimistic Chat Rendering Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Render each submitted user turn immediately, then reconcile it with persistent server IDs before streaming or displaying the assistant response. **Architecture:** `useChat` owns one optimistic turn descriptor containing stable client-only conversation/message IDs and complete attachment metadata. It commits the user turn before the first network await, sends only the existing API payload, and atomically reconciles client IDs when response headers arrive. `ChatComposer` passes full uploaded attachment metadata; the hook alone derives the API attachment ID list. **Tech Stack:** React 19 hooks, Next.js 16 App Router client components, TypeScript, Vitest, Testing Library, Docker, Kubernetes/minikube. ## Global Constraints - Keep the persistent `/api/chat` request and response contract unchanged. - Never send client-only conversation or message IDs to the server. - Keep the optimistic user message after model error or cancellation. - Reconcile server IDs without duplicate user messages. - Preserve authoritative retry; disable retry only for a client-only pre-header failure. - Render complete already-uploaded attachment metadata immediately while sending only attachment IDs. - Do not modify Gateway, FinOps, authentication, database schema, or model protocols. - Follow `corp-ui/AGENTS.md` and bundled Next.js 16 documentation. --- ### Task 1: Optimistic Turn State and Server Reconciliation **Files:** - Modify: `hooks/use-chat.test.tsx` - Modify: `hooks/use-chat.ts` **Interfaces:** - Consumes: `Attachment`, `ChatMessage`, `Conversation`, and `ModelId` from `lib/chat/types.ts`. - Produces: `sendMessage(content: string, attachments?: Attachment[]): Promise`. - Produces internally: `OptimisticTurn` with `conversationId`, `userMessageId`, `createdAt`, and `clientConversation`. - [ ] **Step 1: Write failing immediate-render and reconciliation tests** Add a deferred `Promise` at the mocked HTTP boundary and assert real hook state: ```tsx it("renders the submitted user turn before chat response headers arrive", async () => { let resolveChat!: (response: Response) => void; vi.mocked(authFetch).mockImplementation((input) => { if (input === "/api/conversations") { return Promise.resolve(Response.json({ conversations: [] })); } if (input === "/api/chat") { return new Promise((resolve) => { resolveChat = resolve; }); } if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) { return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") })); } return Promise.resolve(Response.json({})); }); const { result } = renderHook(() => useChat("user-1")); let sending!: Promise; act(() => { sending = result.current.sendMessage("Покажи сразу"); }); await waitFor(() => expect(result.current.activeConversation?.messages).toHaveLength(1)); expect(result.current.activeConversation?.messages[0]).toMatchObject({ role: "user", content: "Покажи сразу", status: "complete", }); await act(async () => { resolveChat(streamResponse(["Готово"], conversationHeaders)); await sending; }); expect(result.current.activeConversation?.id).toBe("conversation-1"); expect(result.current.activeConversation?.messages.map(({ id }) => id)).toEqual([ "user-1", "assistant-1", ]); }); ``` Add a hydrated-history case proving an existing conversation immediately gets exactly one user message and keeps its authoritative conversation ID. - [ ] **Step 2: Run the new tests and verify RED** Run: `npm test -- hooks/use-chat.test.tsx` Expected: the new-chat case sees `activeConversation === null` while `/api/chat` is unresolved; the existing chat message count remains unchanged. - [ ] **Step 3: Implement the minimal optimistic commit** Add client-only prefixes and the internal descriptor: ```ts const CLIENT_MESSAGE_PREFIX = "client-message-"; const CLIENT_CONVERSATION_PREFIX = "client-conversation-"; type OptimisticTurn = { conversationId: string; userMessageId: string; createdAt: string; clientConversation: boolean; }; ``` Before `authFetch`, generate IDs with `crypto.randomUUID()`, append the complete user message, and create/activate a local conversation when no authoritative conversation exists. Update state and synchronous refs through `commitConversations`. Build the network body from `conversationId`, `model`, `content`, `retry`, and derived `attachmentIds` only. Client IDs and `Attachment[]` must never be serialized. - [ ] **Step 4: Implement atomic response-header reconciliation** Replace the optimistic conversation ID only for new chats, replace the optimistic user ID with `X-User-Message-Id`, and append the assistant once. Update the active ID only if that optimistic conversation remains active. Keep retry requests on the existing assistant-only path. ```ts messages: conversation.messages .map((message) => message.id === optimistic.userMessageId ? { ...message, id: userMessageId } : message, ) .concat(assistantMessage) ``` - [ ] **Step 5: Run hook tests and verify GREEN** Run: `npm test -- hooks/use-chat.test.tsx` Expected: all hook tests pass and immediate state is observable before resolving `/api/chat`. - [ ] **Step 6: Commit Task 1** ```bash git add hooks/use-chat.ts hooks/use-chat.test.tsx git commit -m "feat: render optimistic chat turns" ``` --- ### Task 2: Pre-header Errors, Cancellation, and Attachment Metadata **Files:** - Modify: `hooks/use-chat.test.tsx` - Modify: `hooks/use-chat.ts` - Modify: `components/chat/chat-app.test.tsx` - Modify: `components/chat/chat-composer.tsx` **Interfaces:** - Consumes: `sendMessage(content: string, attachments?: Attachment[])` from Task 1. - Preserves: `/api/chat` field `attachmentIds?: string[]`. - Preserves: retry for authoritative assistant IDs. - [ ] **Step 1: Write failing pre-header error and retry-safety test** Reject `/api/chat` before headers and assert: ```tsx expect(result.current.activeConversation?.messages.map((message) => message.role)).toEqual([ "user", "assistant", ]); expect(result.current.activeConversation?.messages[1]).toMatchObject({ status: "error", content: "Не удалось получить ответ. Попробуйте снова.", }); expect(result.current.canRetryLast).toBe(false); ``` The existing aborted-stream test remains the check that a reconciled assistant becomes `stopped` and the user message remains. - [ ] **Step 2: Write failing optimistic attachment test** ```tsx const attachment = { id: "00000000-0000-4000-8000-000000000077", name: "brief.txt", contentType: "text/plain", size: 5, }; act(() => { sending = result.current.sendMessage("Смотри", [attachment]); }); await waitFor(() => expect(result.current.activeConversation?.messages[0].attachments).toEqual([attachment]), ); expect(JSON.parse(String(chatCall?.[1]?.body))).toEqual({ model: "auto", content: "Смотри", attachmentIds: [attachment.id], }); ``` Add a composer-level upload fixture with all four attachment fields and assert `sendMessage` receives the full object rather than `string[]`. - [ ] **Step 3: Run focused tests and verify RED** Run: `npm test -- hooks/use-chat.test.tsx components/chat/chat-app.test.tsx` Expected: no pre-header assistant error exists and composer still passes only attachment IDs. - [ ] **Step 4: Implement safe client-only error state** When no authoritative assistant exists, append one client-only assistant message with `error` or `stopped`. Extend `canRetryLastMessage` to reject IDs beginning with `CLIENT_MESSAGE_PREFIX`. Never remove the optimistic user message. - [ ] **Step 5: Pass full attachment metadata from the composer** Use `Attachment[]` for composer state and parse the complete upload response: ```ts const body = (await response.json()) as { attachment: Attachment }; setAttachments((current) => [...current, body.attachment]); ``` On submit, call `onSend(message, attachments)` when attachments exist. The hook derives `attachmentIds`. - [ ] **Step 6: Run focused tests and verify GREEN** Run: `npm test -- hooks/use-chat.test.tsx components/chat/chat-app.test.tsx` Expected: optimistic error, stopped stream, attachment metadata, exact body, and composer callback tests all pass. - [ ] **Step 7: Commit Task 2** ```bash git add hooks/use-chat.ts hooks/use-chat.test.tsx components/chat/chat-composer.tsx components/chat/chat-app.test.tsx git commit -m "fix: preserve optimistic chat turn state" ``` --- ### Task 3: Regression Verification and Minikube Rollout **Files:** - Verify: all Task 1 and Task 2 files - Runtime: `Dockerfile`, deployment `aegida-services/ai-control-chat-ui` **Interfaces:** - Consumes: completed optimistic chat behavior. - Produces: deployed UI at `https://chat.aegida.test:8443`. - [ ] **Step 1: Run the focused feature matrix** ```bash npm test -- hooks/use-chat.test.tsx components/chat/chat-app.test.tsx components/chat/chat-message.test.tsx app/api/chat/route.test.ts ``` Expected: all focused tests pass. - [ ] **Step 2: Run the full verification matrix** ```bash npm test npm run lint npx tsc --noEmit npm run build git diff --check ``` Expected: every command exits `0`; Next.js emits all application and API routes. - [ ] **Step 3: Build and deploy the exact commit** ```bash git rev-parse --short HEAD minikube image build -t ai-control-chat-ui:minikube- . kubectl -n aegida-services set image deployment/ai-control-chat-ui ai-control-chat-ui=ai-control-chat-ui:minikube- kubectl -n aegida-services rollout status deployment/ai-control-chat-ui --timeout=180s ``` Expected: rollout succeeds with one Ready pod and zero restarts. - [ ] **Step 4: Verify the original behavior through ingress** At `https://chat.aegida.test:8443` submit a prompt that takes several seconds. Before output, observe exactly one user bubble. After completion, observe one user bubble and one assistant answer. Reload and confirm authoritative history. Confirm `/api/health` returns HTTP `200`. - [ ] **Step 5: Report exact evidence** Report the commit, image tag/digest, test counts, Ready/restart state, ingress URL, and observed before/after message counts. Do not claim success without fresh command and browser evidence.