Files
app/docs/superpowers/plans/2026-06-08-odin-conversations.md
BOHA 2565e0de21 docs(odin): implementation plan for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:11:54 +02:00

793 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Odin Multi-Conversation Chat — 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:** Turn the single-thread Odin assistant into a multi-conversation chat (top-tabs, rename/delete, auto-title) on the `/odin` page.
**Architecture:** New `ai_conversations` table; `ai_chat_messages` gains `conversation_id` (FK, cascade). Conversation-scoped service + routes replace the single `/history` endpoints. A new `OdinChat` component (+ focused sub-components) replaces `DashAssistant`, keeping all proven chat/extract/save logic but scoped to the active conversation.
**Tech Stack:** Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (real DB).
**Spec:** `docs/superpowers/specs/2026-06-08-odin-conversations-design.md`.
---
## File map
| File | Responsibility |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `prisma/schema.prisma` | + `ai_conversations`; `ai_chat_messages.conversation_id` + relation + index swap |
| `prisma/migrations/20260608140000_add_ai_conversations/migration.sql` | create table, add column, backfill, enforce FK |
| `src/services/ai.service.ts` | replace `getChatHistory/appendChatMessages/clearChatHistory` with conversation-scoped fns |
| `src/schemas/ai.schema.ts` | + `AiCreateConversationSchema`, `AiRenameConversationSchema`; keep `AiHistoryAppendSchema` (reused for append) |
| `src/routes/admin/ai.ts` | replace `/history` routes with `/conversations*` routes |
| `src/__tests__/ai.test.ts` | replace history tests with conversation tests |
| `src/admin/lib/queries/ai.ts` | + conversation query options/types; remove `aiHistoryOptions` |
| `src/admin/components/odin/OdinTabs.tsx` | conversation tabs + new + rename/delete menu |
| `src/admin/components/odin/OdinThread.tsx` | message list + avatar + empty/busy states |
| `src/admin/components/odin/InvoiceReviewCard.tsx` | editable extracted-invoice card |
| `src/admin/components/odin/OdinComposer.tsx` | staged chips + attach + input + send |
| `src/admin/components/odin/OdinChat.tsx` | orchestrator: queries, state, the ported logic |
| `src/admin/pages/Odin.tsx` | render `OdinChat` (was `DashAssistant`) |
| `src/admin/components/dashboard/DashAssistant.tsx` | **deleted** |
**Controller note (migration):** Tasks that touch the DB require the dev server stopped. After Task 1's files are written, the **controller** runs: ask user to stop dev server → `npx prisma generate``npx prisma migrate deploy` (applies to dev `app` DB) → resume. Backend tsc is red between Task 1 and Task 2 (data model changes before the service is updated) — that is expected; Task 2 restores green.
---
## Task 1: Data model + migration
**Files:**
- Modify: `prisma/schema.prisma`
- Create: `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`
- [ ] **Step 1: Edit `prisma/schema.prisma`** — replace the existing `ai_chat_messages` model and add `ai_conversations` above it:
```prisma
model ai_conversations {
id Int @id @default(autoincrement())
user_id Int
title String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
messages ai_chat_messages[]
@@index([user_id, id], map: "idx_ai_conv_user")
}
model ai_chat_messages {
id Int @id @default(autoincrement())
user_id Int
conversation_id Int
role String @db.VarChar(20)
content String @db.Text
created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@index([conversation_id, id], map: "idx_ai_chat_conv")
}
```
- [ ] **Step 2: Write the migration SQL** at `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`:
```sql
-- Multi-conversation Odin: conversations table + conversation_id on messages.
CREATE TABLE `ai_conversations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`title` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_conv_user` (`user_id`, `id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `ai_chat_messages`
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
-- Backfill: one conversation per user who has messages ("keep as first conversation").
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
SELECT `user_id`, 'Konverzace', NOW(), NOW()
FROM `ai_chat_messages`
GROUP BY `user_id`;
UPDATE `ai_chat_messages` m
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
SET m.`conversation_id` = c.`id`;
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
ALTER TABLE `ai_chat_messages`
DROP INDEX `idx_ai_chat_user`,
MODIFY `conversation_id` INTEGER NOT NULL,
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
```
- [ ] **Step 3: Controller applies the migration** (not the implementer):
- Ask user to stop the dev server (Prisma engine DLL is locked on Windows otherwise).
- `npx prisma generate` — expect "Generated Prisma Client".
- `npx prisma migrate deploy` — expect "Applying migration `20260608140000_add_ai_conversations`" → "successfully applied".
- [ ] **Step 4: Commit**
```bash
git add prisma/schema.prisma prisma/migrations/20260608140000_add_ai_conversations
git commit -m "feat(odin): ai_conversations table + conversation_id on messages"
```
---
## Task 2: Backend — conversation service, schemas, routes
**Files:**
- Modify: `src/services/ai.service.ts`
- Modify: `src/schemas/ai.schema.ts`
- Modify: `src/routes/admin/ai.ts`
- [ ] **Step 1: Replace the chat-history block in `ai.service.ts`.** Delete `getChatHistory`, `appendChatMessages`, `clearChatHistory` and the `HISTORY_LIMIT` const, and put this in their place (keep the `StoredChatMessage` interface):
```ts
// ── Conversations (server-side, per user) ─────────────────────────────────
export interface StoredChatMessage {
role: string;
content: string;
created_at: Date;
}
export interface ConversationSummary {
id: number;
title: string;
updated_at: Date;
}
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
const MESSAGE_LIMIT = 200; // cap a single thread read
export async function listConversations(
userId: number,
): Promise<ConversationSummary[]> {
return prisma.ai_conversations.findMany({
where: { user_id: userId },
orderBy: { id: "asc" }, // stable tab order
select: { id: true, title: true, updated_at: true },
});
}
export async function createConversation(
userId: number,
title?: string,
): Promise<ConversationSummary> {
return prisma.ai_conversations.create({
data: {
user_id: userId,
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
},
select: { id: true, title: true, updated_at: true },
});
}
/** Ownership-checked fetch; null when not found / not owned. */
async function ownConversation(userId: number, convId: number) {
return prisma.ai_conversations.findFirst({
where: { id: convId, user_id: userId },
select: { id: true, title: true },
});
}
export async function getConversationMessages(
userId: number,
convId: number,
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const rows = await prisma.ai_chat_messages.findMany({
where: { conversation_id: convId },
orderBy: { id: "desc" },
take: MESSAGE_LIMIT,
select: { role: true, content: true, created_at: true },
});
return { data: rows.reverse() };
}
export async function appendConversationMessages(
userId: number,
convId: number,
messages: { role: string; content: string }[],
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
const conv = await ownConversation(userId, convId);
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
if (messages.length > 0) {
await prisma.ai_chat_messages.createMany({
data: messages.map((m) => ({
user_id: userId,
conversation_id: convId,
role: m.role,
content: m.content,
})),
});
}
// Auto-title from the first user message (only while still the default), and
// always bump updated_at.
const firstUser = messages.find((m) => m.role === "user");
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
if (t) data.title = t;
}
await prisma.ai_conversations.update({ where: { id: convId }, data });
return { data: { ok: true } };
}
export async function renameConversation(
userId: number,
convId: number,
title: string,
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const updated = await prisma.ai_conversations.update({
where: { id: convId },
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
select: { id: true, title: true, updated_at: true },
});
return { data: updated };
}
export async function deleteConversation(
userId: number,
convId: number,
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
return { data: { ok: true } };
}
```
- [ ] **Step 2: Schemas** — in `src/schemas/ai.schema.ts`, keep `AiHistoryAppendSchema` (reused for append) and add:
```ts
export const AiCreateConversationSchema = z.object({
title: z.string().max(255).optional(),
});
export const AiRenameConversationSchema = z.object({
title: z.string().min(1, "Název je povinný").max(255),
});
```
- [ ] **Step 3: Routes** — in `src/routes/admin/ai.ts`: update imports (drop `getChatHistory/appendChatMessages/clearChatHistory`, add the new fns; add `AiCreateConversationSchema, AiRenameConversationSchema`; import `parseId` from `"../../schemas/common"`). Delete the three `/history` route blocks and append these (all `requirePermission("ai.use")`):
```ts
// GET /conversations — this user's conversations (stable order)
app.get(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const conversations = await listConversations(request.authData!.userId);
return success(reply, { conversations });
},
);
// POST /conversations — create
app.post(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(AiCreateConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const conversation = await createConversation(
request.authData!.userId,
body.data.title,
);
return success(reply, { conversation });
},
);
// GET /conversations/:id/messages
app.get(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await getConversationMessages(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { messages: result.data });
},
);
// POST /conversations/:id/messages — append turns
app.post(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiHistoryAppendSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await appendConversationMessages(
request.authData!.userId,
id,
body.data.messages,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
// PATCH /conversations/:id — rename
app.patch(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiRenameConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await renameConversation(
request.authData!.userId,
id,
body.data.title,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { conversation: result.data });
},
);
// DELETE /conversations/:id
app.delete(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await deleteConversation(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
```
- [ ] **Step 4: Gate**
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success.
- [ ] **Step 5: Commit**
```bash
git add src/services/ai.service.ts src/schemas/ai.schema.ts src/routes/admin/ai.ts
git commit -m "feat(odin): conversation-scoped service + routes (replace /history)"
```
---
## Task 3: Backend tests
**Files:**
- Modify: `src/__tests__/ai.test.ts`
- [ ] **Step 1: Replace the imports + cleanup markers.** Swap the history-fn imports for the conversation fns, and update the top-of-file constants/cleanup:
```ts
import {
computeCostUsd,
recordUsage,
getMonthSpendUsd,
getBudgetUsd,
assertBudgetAvailable,
isConfigured,
listConversations,
createConversation,
getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
} from "../services/ai.service";
const KIND = "test_ai";
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
const CONV_UID_B = 999999992;
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
```
Update the top-level cleanup (`afterAll` and the conversation `beforeEach`) to delete by these. Deleting a conversation cascades its messages:
```ts
afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
await prisma.ai_conversations.deleteMany({
where: { title: { contains: HIST_MARKER } },
});
});
```
- [ ] **Step 2: Replace the `describe("ai.service chat history", …)` block** with conversation service tests:
```ts
describe("ai.service conversations", () => {
beforeEach(async () => {
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
});
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
const conv = await createConversation(CONV_UID_A);
expect(conv.title).toBe("Nová konverzace");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "Kolik je hodin v Praze?" },
{ role: "assistant", content: "Nevím, ale…" },
]);
const list = await listConversations(CONV_UID_A);
expect(list).toHaveLength(1);
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
expect("data" in msgs && msgs.data.map((m) => m.content)).toEqual([
"Kolik je hodin v Praze?",
"Nevím, ale…",
]);
});
it("does not overwrite a renamed title on later appends", async () => {
const conv = await createConversation(CONV_UID_A);
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "tohle by nemělo přepsat název" },
]);
const list = await listConversations(CONV_UID_A);
expect(list[0].title).toBe("Moje téma");
});
it("isolates conversations per user (404 across owners)", async () => {
const a = await createConversation(CONV_UID_A);
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
const read = await getConversationMessages(CONV_UID_B, a.id);
expect("error" in read && read.status).toBe(404);
const ren = await renameConversation(CONV_UID_B, a.id, "hack");
expect("error" in ren && ren.status).toBe(404);
const del = await deleteConversation(CONV_UID_B, a.id);
expect("error" in del && del.status).toBe(404);
});
it("cascade-deletes messages with the conversation", async () => {
const conv = await createConversation(CONV_UID_A);
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "x" },
]);
await deleteConversation(CONV_UID_A, conv.id);
const remaining = await prisma.ai_chat_messages.count({
where: { conversation_id: conv.id },
});
expect(remaining).toBe(0);
});
});
```
- [ ] **Step 3: Replace the HTTP history tests** (the `/history` `it(...)` blocks) with conversation HTTP tests inside the existing `describe("HTTP /api/admin/ai", …)`:
```ts
it("GET /conversations requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} t` },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.conversation.id as number;
const app2 = await app.inject({
method: "POST",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(app2.statusCode).toBe(200);
const msgs = await app.inject({
method: "GET",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(
msgs.json().data.messages.map((m: { content: string }) => m.content),
).toContain("ahoj");
const renamed = await app.inject({
method: "PATCH",
url: `/api/admin/ai/conversations/${id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} renamed` },
});
expect(renamed.json().data.conversation.title).toBe(`${HIST_MARKER} renamed`);
const del = await app.inject({
method: "DELETE",
url: `/api/admin/ai/conversations/${id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(del.statusCode).toBe(200);
});
it("GET messages of a non-existent conversation → 404", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations/999999000/messages",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
```
- [ ] **Step 4: Gate**
Run: `npx vitest run src/__tests__/ai.test.ts` — expect all passing. Then `npx vitest run` — expect the whole suite green.
- [ ] **Step 5: Commit**
```bash
git add src/__tests__/ai.test.ts
git commit -m "test(odin): conversation CRUD, isolation, auto-title, cascade"
```
---
## Task 4: Frontend queries + types
**Files:**
- Modify: `src/admin/lib/queries/ai.ts`
- [ ] **Step 1: Add conversation options.** Keep everything (incl. `aiHistoryOptions` for now — it's removed in Task 6 alongside its only consumer `DashAssistant.tsx`, so every task stays green). Add:
```ts
export interface Conversation {
id: number;
title: string;
updated_at: string;
}
export const aiConversationsOptions = () =>
queryOptions({
queryKey: ["ai", "conversations"],
queryFn: () =>
jsonQuery<{ conversations: Conversation[] }>(
"/api/admin/ai/conversations",
),
staleTime: 30_000,
});
export const aiConversationMessagesOptions = (id: number) =>
queryOptions({
queryKey: ["ai", "conversation", id, "messages"],
queryFn: () =>
jsonQuery<{ messages: StoredChatMessage[] }>(
`/api/admin/ai/conversations/${id}/messages`,
),
// Re-read the DB on each mount/selection (no stale-cache resurrection).
staleTime: Infinity,
gcTime: 0,
});
```
- [ ] **Step 2: Gate**
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (we kept `aiHistoryOptions`, so `DashAssistant.tsx` still compiles).
- [ ] **Step 3: Commit**
```bash
git add src/admin/lib/queries/ai.ts
git commit -m "feat(odin): conversation query options"
```
---
## Task 5: Frontend presentational components
**Files:**
- Create: `src/admin/components/odin/OdinTabs.tsx`
- Create: `src/admin/components/odin/OdinThread.tsx`
- Create: `src/admin/components/odin/InvoiceReviewCard.tsx`
- Create: `src/admin/components/odin/OdinComposer.tsx`
These are **props-driven** (no data fetching). Port presentational details (bubble styling, the `Typography`-color fix, review-card fields, staged chips) from the current `src/admin/components/dashboard/DashAssistant.tsx`, which the implementer should read first.
- [ ] **Step 1: Shared types** — define at the top of `OdinChat.tsx` (Task 6) and import where needed, OR duplicate the tiny `ChatTurn` shape. Use these exact shapes across files:
```ts
export interface ChatTurn {
role: "user" | "assistant";
content: string;
}
export interface ReviewInvoice {
uid: string;
supplier_name: string;
invoice_number: string;
amount: string;
currency: string;
vat_rate: string;
issue_date: string;
due_date: string;
description: string;
file_name: string;
file: File;
}
export type EditableField =
| "supplier_name"
| "invoice_number"
| "amount"
| "currency"
| "vat_rate"
| "issue_date"
| "due_date"
| "description";
export interface StagedFile {
id: string;
file: File;
}
```
Put these in a new `src/admin/components/odin/types.ts` and import from there in every odin file.
- [ ] **Step 2: `OdinTabs.tsx`** — props and behavior:
```tsx
interface OdinTabsProps {
conversations: { id: number; title: string }[];
activeId: number | null;
busy: boolean;
onSelect: (id: number) => void;
onNew: () => void;
onRename: (id: number, title: string) => void;
onDelete: (id: number) => void;
}
```
Render a horizontally-scrollable row (`Box` with `overflowX: "auto"`, `display: flex`, `gap`). Each conversation is a clickable pill (`Button` size small; active = `contained`, else `outlined color="inherit"`). After the pills, a `+` `Button` calls `onNew`. The **active** pill shows a small `⋮` `IconButton` opening an `@mui/material` `Menu` with "Přejmenovat" and "Smazat". "Přejmenovat" opens the kit `Modal` containing a `TextField` (prefilled with current title) → on submit calls `onRename`. "Smazat" opens the kit `ConfirmDialog` → on confirm calls `onDelete`. All actions disabled while `busy`.
- [ ] **Step 3: `OdinThread.tsx`** — props:
```tsx
interface OdinThreadProps {
turns: ChatTurn[];
busy: boolean;
loading: boolean;
threadRef: React.RefObject<HTMLDivElement>;
}
```
Render the scrolling message area (`flex: 1, minHeight: 0, overflowY: "auto"`). Assistant turns left with a small circular Odin avatar (`Box` 28px, `borderRadius: "50%"`, `bgcolor: "primary.main"`, white "O"); user turns right-aligned `primary.main` bubble. **Message colour MUST be on the `Typography`** (`color: user ? "common.white" : "text.primary"`) — GlobalStyles pins `p` to text.secondary. Empty state (no turns, not busy, not loading): centered Odin avatar + "Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu." When `loading`: "Načítám…". When `busy`: a `CircularProgress size={14}` + "Pracuji…" row.
- [ ] **Step 4: `InvoiceReviewCard.tsx`** — props:
```tsx
interface InvoiceReviewCardProps {
inv: ReviewInvoice;
busy: boolean;
onChange: (uid: string, field: EditableField, value: string) => void;
onSave: (inv: ReviewInvoice) => void;
onDiscard: (uid: string) => void;
}
```
Port the card body from `DashAssistant.tsx` verbatim (the `Chip "Faktura"` + filename header, the 2-col grid of `TextField`s including **"Částka s DPH"**, "Datum splatnosti", "Popis", and the Uložit / Zahodit buttons). Replace `patch(...)``onChange(...)`, `saveInvoice``onSave`, the discard filter→`onDiscard(inv.uid)`.
- [ ] **Step 5: `OdinComposer.tsx`** — props:
```tsx
interface OdinComposerProps {
input: string;
attachments: StagedFile[];
busy: boolean;
canSubmit: boolean;
fileRef: React.RefObject<HTMLInputElement>;
onInput: (v: string) => void;
onFiles: (files: FileList | null) => void;
onRemoveAttachment: (id: string) => void;
onSubmit: () => void;
}
```
Port the staged-attachment chips + the composer row (hidden file `input`, "Přiložit" button, `TextField` with Enter-to-submit, "Odeslat" button) from `DashAssistant.tsx`. Disable per `busy` / `!canSubmit`.
- [ ] **Step 6: Gate**
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (these compile independently of the orchestrator). If a component is unused at this point, that is fine — Task 6 wires them.
- [ ] **Step 7: Commit**
```bash
git add src/admin/components/odin
git commit -m "feat(odin): tabs, thread, review-card, composer components"
```
---
## Task 6: OdinChat orchestrator + page + delete DashAssistant
**Files:**
- Create: `src/admin/components/odin/OdinChat.tsx`
- Modify: `src/admin/pages/Odin.tsx`
- Modify: `src/admin/lib/queries/ai.ts` (remove `aiHistoryOptions`)
- Delete: `src/admin/components/dashboard/DashAssistant.tsx`
- [ ] **Step 1: Write `OdinChat.tsx`.** It owns all state + queries + the ported submit/extract/save logic, scoped to the active conversation. Port the helper fns (`summaryNote`, `nextUid`, `plural`, `MODEL_CONTEXT`, `MAX_STORE`) and the **submit / saveInvoice / onFiles / persist** logic from `DashAssistant.tsx` with these adaptations:
- **Queries:** `usage` (unchanged), `aiConversationsOptions()`, and `aiConversationMessagesOptions(activeId)` (enabled when `activeId != null`).
- **State:** `activeId: number | null`, plus `input`, `busy`, `turns`, `review`, `attachments`, `fileRef`, `threadRef`.
- **Ensure a conversation exists:** when the conversations query resolves: if empty, `POST /conversations` to create a default and `setActiveId(new.id)` + invalidate `["ai","conversations"]`; else if `activeId == null`, `setActiveId(list[0].id)`.
- **Seed per conversation:** a `seededId = useRef<number|null>(null)`. Effect keyed on `[activeId, messagesData]`: when `messagesData` for the active id arrives and `seededId.current !== activeId`, set `seededId.current = activeId`, `setTurns(messagesData.messages.map(m => ({role:m.role, content:m.content})))`, `setReview([])`.
- **Switch tab (`onSelect`):** `setActiveId(id); setTurns([]); setReview([]); seededId.current = null;` (force re-seed; the messages query for the new id loads and seeds).
- **`persist(msgs)`** posts to `/api/admin/ai/conversations/${activeId}/messages` (was `/history`); best-effort + `console.error` on failure.
- **`submit`:** identical to `DashAssistant.submit` EXCEPT it first ensures `activeId` (if null, await-create one and use it); marks `seededId.current = activeId`; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. Gate `canSubmit` on `!conversationsPending && activeId != null && !busy && (input.trim() || attachments.length)`.
- **`onNew`:** `POST /conversations` → select it (`setActiveId`, clear turns/review, `seededId.current=null`) + invalidate `["ai","conversations"]`.
- **`onRename(id, title)`:** `PATCH /conversations/:id` → invalidate `["ai","conversations"]`.
- **`onDelete(id)`:** `DELETE /conversations/:id` → invalidate `["ai","conversations"]`; if `id === activeId`, pick another conversation from the (refetched) list or null.
- **Layout:** a full-height shell — `Box sx={{ height: "calc(100dvh - 140px)", display: "flex", flexDirection: "column" }}` containing: header (`Odin` + budget `Utraceno: $x / $y`), `<OdinTabs … />`, `<OdinThread … />` (flex 1), the review list (`review.map(inv => <InvoiceReviewCard … />)` in a bounded `maxHeight: "35vh", overflowY: "auto"` box), and `<OdinComposer … />`.
- **Self-hide:** keep `if (usage && usage.configured === false) return null;`.
Reference the exact bodies of `submit`, `saveInvoice`, `onFiles`, `summaryNote`, `nextUid` in the current `DashAssistant.tsx` (lines ~52333) — copy them, applying only the adaptations above.
- [ ] **Step 2: Update `src/admin/pages/Odin.tsx`** — swap the import and render:
```tsx
import OdinChat from "../components/odin/OdinChat";
// …
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
<OdinChat />
</Box>;
```
- [ ] **Step 3: Delete `src/admin/components/dashboard/DashAssistant.tsx`** and remove the now-orphaned `aiHistoryOptions` from `src/admin/lib/queries/ai.ts` (DashAssistant was its only consumer).
```bash
git rm src/admin/components/dashboard/DashAssistant.tsx
```
- [ ] **Step 4: Gate**
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success. Run: `npx vitest run` — expect the full suite green.
- [ ] **Step 5: Commit**
```bash
git add src/admin/components/odin/OdinChat.tsx src/admin/pages/Odin.tsx src/admin/lib/queries/ai.ts
git commit -m "feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant"
```
---
## Final verification (controller)
- `npx tsc -b --noEmit` = 0, `npm run build` = success, `npx vitest run` = all green.
- Manual (user/controller): create a conversation, send a chat, attach a PDF → review → save, rename a tab, delete a tab, reload (history persists per conversation), switch tabs (each keeps its own thread).
- Adversarial review pass (workflow) before merge/release.