32 KiB
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 existingai_chat_messagesmodel and addai_conversationsabove it:
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:
-- 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 migration20260608140000_add_ai_conversations" → "successfully applied".
-
Step 4: Commit
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. DeletegetChatHistory,appendChatMessages,clearChatHistoryand theHISTORY_LIMITconst, and put this in their place (keep theStoredChatMessageinterface):
// ── 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, keepAiHistoryAppendSchema(reused for append) and add:
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 (dropgetChatHistory/appendChatMessages/clearChatHistory, add the new fns; addAiCreateConversationSchema, AiRenameConversationSchema; importparseIdfrom"../../schemas/common"). Delete the three/historyroute blocks and append these (allrequirePermission("ai.use")):
// 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
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:
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:
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:
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
/historyit(...)blocks) with conversation HTTP tests inside the existingdescribe("HTTP /api/admin/ai", …):
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
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.
aiHistoryOptionsfor now — it's removed in Task 6 alongside its only consumerDashAssistant.tsx, so every task stays green). Add:
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
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 tinyChatTurnshape. Use these exact shapes across files:
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:
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:
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:
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 TextFields 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:
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
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(removeaiHistoryOptions) -
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 fromDashAssistant.tsxwith these adaptations:- Queries:
usage(unchanged),aiConversationsOptions(), andaiConversationMessagesOptions(activeId)(enabled whenactiveId != null). - State:
activeId: number | null, plusinput,busy,turns,review,attachments,fileRef,threadRef. - Ensure a conversation exists: when the conversations query resolves: if empty,
POST /conversationsto create a default andsetActiveId(new.id)+ invalidate["ai","conversations"]; else ifactiveId == null,setActiveId(list[0].id). - Seed per conversation: a
seededId = useRef<number|null>(null). Effect keyed on[activeId, messagesData]: whenmessagesDatafor the active id arrives andseededId.current !== activeId, setseededId.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.erroron failure.submit: identical toDashAssistant.submitEXCEPT it first ensuresactiveId(if null, await-create one and use it); marksseededId.current = activeId; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. GatecanSubmiton!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"]; ifid === 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+ budgetUtraceno: $x / $y),<OdinTabs … />,<OdinThread … />(flex 1), the review list (review.map(inv => <InvoiceReviewCard … />)in a boundedmaxHeight: "35vh", overflowY: "auto"box), and<OdinComposer … />. - Self-hide: keep
if (usage && usage.configured === false) return null;.
- Queries:
Reference the exact bodies of submit, saveInvoice, onFiles, summaryNote, nextUid in the current DashAssistant.tsx (lines ~52–333) — copy them, applying only the adaptations above.
- Step 2: Update
src/admin/pages/Odin.tsx— swap the import and render:
import OdinChat from "../components/odin/OdinChat";
// …
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
<OdinChat />
</Box>;
- Step 3: Delete
src/admin/components/dashboard/DashAssistant.tsxand remove the now-orphanedaiHistoryOptionsfromsrc/admin/lib/queries/ai.ts(DashAssistant was its only consumer).
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
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.