aegida-console / docs / superpowers / plans / 2026-08-08-optimistic-chat-rendering.md
2026-08-08-optimistic-chat-rendering.md
Raw

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<void>.

  • Produces internally: OptimisticTurn with conversationId, userMessageId, createdAt, and clientConversation.

  • Step 1: Write failing immediate-render and reconciliation tests

Add a deferred Promise<Response> at the mocked HTTP boundary and assert real hook state:

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<Response>((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<void>;
  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:

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.

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
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:

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
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:

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
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

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
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
git rev-parse --short HEAD
minikube image build -t ai-control-chat-ui:minikube-<short-sha> .
kubectl -n aegida-services set image deployment/ai-control-chat-ui ai-control-chat-ui=ai-control-chat-ui:minikube-<short-sha>
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.