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.
/api/chat request and response contract unchanged.corp-ui/AGENTS.md and bundled Next.js 16 documentation.Files:
hooks/use-chat.test.tsxhooks/use-chat.tsInterfaces:
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.
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.
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.
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)
Run: npm test -- hooks/use-chat.test.tsx
Expected: all hook tests pass and immediate state is observable before resolving /api/chat.
git add hooks/use-chat.ts hooks/use-chat.test.tsx
git commit -m "feat: render optimistic chat turns"
Files:
hooks/use-chat.test.tsxhooks/use-chat.tscomponents/chat/chat-app.test.tsxcomponents/chat/chat-composer.tsxInterfaces:
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.
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[].
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.
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.
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.
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.
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"
Files:
Dockerfile, deployment aegida-services/ai-control-chat-uiInterfaces:
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.
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.
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.
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.
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.