Skip to content

feat: persist AI assistant chat history so conversations can be resumed (JEF-65) - #182

Merged
mankatcheung merged 10 commits into
mainfrom
worktree-jef-65-chat-history
Aug 1, 2026
Merged

mankatcheung merged 10 commits into
mainfrom
worktree-jef-65-chat-history

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

The assistant's conversation was entirely ephemeral: assistant.tsx kept it in a plain useState, and sendChatMessage took the whole prior history as a client-supplied argument on every call — a refresh or navigating away lost everything. This adds server-side persistence, scoped per user like every other entity (cascade-deletes with the user, same as LoginEvent/Session).

Backend (Clean Architecture layers, following LoginEvent's shape as the closest existing precedent — a simple user-scoped table with no application-level parent):

  • New Message domain entity + Message table (userId, role, content, createdAt), cascade-deletes on user.
  • IMessageRepository / DrizzleMessageRepository: create, findAllByUserId, deleteAllByUserId.
  • GetChatHistoryUseCase and ClearChatHistoryUseCase — new chatHistory query and clearChatHistory mutation.
  • ChatWithAssistantUseCase: loads history from the repository instead of a client-supplied history array, and persists both the user's message and the assistant's reply after a successful response (not on rate-limit/AI_NOT_CONFIGURED failures, so there's no dangling question with no answer). This also let the role-validation check be deleted — history is DB-sourced now, never client-controlled, so the injection concern it guarded against no longer applies. sendChatMessage's history argument and the now-unused ChatMessageInput GraphQL type are removed.

Frontend:

  • assistant.tsx gets a route loader (same ensureQueryData pattern as JEF-64) that prefetches chatHistory. The component seeds its local messages state synchronously from the already-populated query cache in a useState initializer, rather than useQuery + an effect — avoids a render where messages is briefly empty before the history loads in, and keeps the existing optimistic-append send flow unchanged.
  • New "Clear" button (confirm() guard, matching the existing window.confirm pattern used for deleting an application) calls clearChatHistory, resets local state, and writes the emptied result back into the query cache so a subsequent navigation away and back doesn't show stale cached history.

Ref: Linear JEF-65.

Test plan

  • pnpm --filter @job-finder/api typecheck / lint / build — clean
  • pnpm --filter @job-finder/web typecheck / lint / build — clean
  • pnpm --filter @job-finder/api test — 879/879 passing (new repository/mapper/use-case tests, plus ChatWithAssistantUseCase's tests updated for the new load-from-repo / persist-after-success behavior — including that a rate-limited or unconfigured-AI turn persists nothing)
  • pnpm --filter @job-finder/web test — 154/154 passing (new AssistantPage test covering seeded history, sending, and clear+confirm — required adding an Element.scrollIntoView polyfill to the shared jsdom test setup, a pre-existing gap since this page never had a test before)
  • Manually verified end-to-end against a live dev server + real SQLite DB: confirmed a failed send (no LLM key configured) persists nothing, then seeded messages directly in the DB and confirmed via Playwright that the assistant page renders the persisted conversation on load, and that clearing empties both the UI and the database (a page reload after clearing still shows the empty state)

🤖 Generated with Claude Code

https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM

Summary by CodeRabbit

  • New Features

    • Added persistent conversations with saved chat messages and automatically generated titles.
    • Added conversation creation, listing, history retrieval and deletion.
    • Chat history now loads automatically and remains available across sessions.
    • Added a confirmation flow for clearing chat history.
    • Added ownership checks to protect conversations and messages.
  • Bug Fixes

    • Improved handling of missing or unauthorised conversations.
  • Tests

    • Expanded coverage for chat persistence, conversation management, history loading, deletion and access control.

…ed (JEF-65)

The assistant's conversation was entirely ephemeral: assistant.tsx kept
it in a plain useState, and sendChatMessage took the whole prior
history as a client-supplied argument on every call — a refresh or
navigating away lost everything. This adds server-side persistence,
scoped per user like every other entity (cascade-deletes with the
user, same as LoginEvent/Session).

Backend (Clean Architecture layers, following the LoginEvent feature's
shape as the closest existing precedent — a simple user-scoped table
with no application-level parent):
- New Message domain entity + Message table (userId, role, content,
  createdAt), cascade-deletes on user.
- IMessageRepository / DrizzleMessageRepository: create,
  findAllByUserId, deleteAllByUserId.
- GetChatHistoryUseCase and ClearChatHistoryUseCase — new chatHistory
  query and clearChatHistory mutation.
- ChatWithAssistantUseCase: loads history from the repository instead
  of a client-supplied `history` array, and persists both the user's
  message and the assistant's reply after a successful response (not
  on rate-limit/AI_NOT_CONFIGURED failures, so there's no dangling
  question with no answer). This also let the role-validation check
  be deleted — history is DB-sourced now, never client-controlled, so
  the injection concern it guarded against no longer applies.
  sendChatMessage's `history` argument and the now-unused
  ChatMessageInput GraphQL type are removed.

Frontend:
- assistant.tsx gets a route loader (same ensureQueryData pattern as
  JEF-64) that prefetches chatHistory. The component seeds its local
  `messages` state synchronously from the already-populated query
  cache in a useState initializer, rather than useQuery + an effect —
  avoids a render where messages is briefly empty before the history
  loads in, and keeps the existing optimistic-append send flow
  unchanged.
- New "Clear" button (confirm() guard, matching the existing
  window.confirm pattern used for deleting an application) calls
  clearChatHistory, resets local state, and writes the emptied result
  back into the query cache so a subsequent navigation away and back
  doesn't show stale cached history.

Verified: typecheck/lint/build clean for both apps. 879/879 API tests
passing (new repository/mapper/use-case tests, plus
ChatWithAssistantUseCase's tests updated for the new load-from-repo /
persist-after-success behavior — including that a rate-limited or
unconfigured-AI turn persists nothing). 154/154 web tests passing (new
AssistantPage test covering seeded history, sending, and clear+confirm
— required adding an Element.scrollIntoView polyfill to the shared
jsdom test setup, a pre-existing gap since this page never had a test
before). Manually verified end-to-end against a live dev server + real
SQLite DB: confirmed a failed send (no LLM key configured) persists
nothing, then seeded messages directly in the DB and confirmed via
Playwright that the assistant page renders the persisted conversation
on load, and that clearing empties both the UI and the database (a
page reload after clearing still shows the empty state).

Ref: JEF-65
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mankatcheung, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 122910ea-2fbf-4f14-ab50-28b6ad944379

📥 Commits

Reviewing files that changed from the base of the PR and between 147fe5f and cfd2b94.

📒 Files selected for processing (10)
  • apps/api/drizzle/0005_confused_satana.sql
  • apps/api/drizzle/meta/0005_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/__tests__/helpers/createTestDb.ts
  • apps/api/src/__tests__/helpers/mocks.ts
  • apps/api/src/constants.ts
  • apps/api/src/http/container.ts
  • apps/api/src/infrastructure/db/schema.ts
  • apps/web/src/__tests__/components/AssistantPage.test.tsx
  • apps/web/src/routes/_authenticated/assistant.tsx

Walkthrough

The change adds persisted conversations and messages. The API adds storage, use cases, repositories, GraphQL operations, and ownership checks. The web assistant loads cached history, sends conversation identifiers, and supports confirmed conversation clearing.

Changes

Persisted chat

Layer / File(s) Summary
Conversation storage and contracts
apps/api/drizzle/*, apps/api/src/domain/*, apps/api/src/infrastructure/db/*, apps/api/src/use-cases/ports/*, apps/api/src/__tests__/helpers/createTestDb.ts, apps/api/src/__tests__/infrastructure/db/repositories/*
Added Conversation and Message entities, SQLite tables, migrations, repository ports, Drizzle repositories, cascade relationships, indexes, and integration coverage.
Conversation and assistant use cases
apps/api/src/use-cases/chat/*, apps/api/src/use-cases/conversations/*, apps/api/src/constants.ts, apps/api/src/__tests__/application/*, apps/api/src/__tests__/helpers/mocks.ts
Chat now loads and stores conversation messages, checks ownership, derives titles, and uses a 50-character title limit. Added create, list, delete, and history use cases with unit coverage.
GraphQL conversation surface
apps/api/src/http/container.ts, apps/api/src/http/schema/*, apps/api/src/interface-adapters/mappers/*, apps/api/src/__tests__/interface-adapters/mappers/*
Added authenticated conversation queries and mutations, message and conversation GraphQL types, DTO mappers, and dependency-injection registrations.
Web assistant history flow
apps/web/src/routes/_authenticated/assistant.tsx, apps/web/src/__tests__/components/AssistantPage.test.tsx, apps/web/src/__tests__/setup.ts, apps/web/src/routeTree.gen.ts
The assistant prefetches history with React Query, sends only the message and conversation identifier, and clears history after confirmation. Added UI tests and generated route metadata updates.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AssistantPage
  participant GraphQL
  participant GetChatHistoryUseCase
  participant DrizzleMessageRepository
  AssistantPage->>GraphQL: request chatHistory(conversationId)
  GraphQL->>GetChatHistoryUseCase: execute(userId, conversationId)
  GetChatHistoryUseCase->>DrizzleMessageRepository: findAllByConversationId(conversationId)
  DrizzleMessageRepository-->>GetChatHistoryUseCase: ordered messages
  GetChatHistoryUseCase-->>GraphQL: message history
  GraphQL-->>AssistantPage: cached chat history
Loading

Possibly related issues

  • Issue 179: The PR implements server-side persistence for assistant conversations and messages.

Possibly related PRs

Poem

A rabbit stores each chat with care,
In tables deep and queries fair.
Messages hop from user to hare,
Titles bloom, then history’s there.
Clear the trail, and start anew. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarises the main change: persisting AI assistant chat history so users can resume conversations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch worktree-jef-65-chat-history
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-jef-65-chat-history

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Extends the single-thread chat history from the previous commit to
support multiple named conversations per user, ChatGPT-sidebar style.

- New Conversation entity/table (userId, title, timestamps), cascades
  to the user like everything else. Message now belongs to a
  conversationId instead of directly to a userId — a conversation is
  the actual ownership boundary, messages are just its contents.
- IConversationRepository / DrizzleConversationRepository: create,
  findById, findAllByUserId (newest-updated first, for the sidebar),
  updateTitle, delete (cascades to its messages at the DB level).
- CreateConversationUseCase, ListConversationsUseCase,
  DeleteConversationUseCase (replaces the old ClearChatHistoryUseCase
  — clearing is now "delete this conversation" instead of wiping the
  single thread). GetChatHistoryUseCase and ChatWithAssistantUseCase
  both now take a conversationId and verify it belongs to the
  requesting user (NOT_FOUND / FORBIDDEN), matching the existing
  GetApplicationUseCase ownership-check pattern.
- ChatWithAssistantUseCase auto-derives a conversation's title from
  its first message (truncated to 50 chars) once history is empty —
  no extra LLM call, just a substring of what the user actually typed.
- GraphQL: new `conversations` query and `createConversation` /
  `deleteConversation` mutations; `chatHistory` and `sendChatMessage`
  now take a `conversationId` argument. Removed the now-unused
  `clearChatHistory` mutation.

Migration regenerated from scratch (0003_confused_satana.sql) rather
than layered as an alter on top of the previous commit's migration —
that one was never applied to any real database (only ephemeral CI
preview DBs), so there's no shipped state to preserve.

Verified: typecheck/lint/build clean, 900/900 API tests passing
(new repository/mapper/use-case tests for Conversation, plus
ChatWithAssistantUseCase/GetChatHistoryUseCase tests updated for
conversation-scoped ownership checks and title derivation).

Ref: JEF-65

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/routes/_authenticated/assistant.tsx (1)

109-116: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Synchronise successful sends into the chat-history cache.

handleSend only updates local messages; the loader-seeded ['chatHistory'] cache remains unchanged. A later route entry can therefore reload the old persisted value and omit messages that were already saved. Append both user and assistant messages to chatHistory after send.mutateAsync succeeds, and add a regression test that remounts the page after a successful send.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/assistant.tsx` around lines 109 - 116, The
handleSend flow must also update the ['chatHistory'] query cache after
send.mutateAsync succeeds, appending both the user message and returned
assistant message while preserving existing history. Add a regression test that
performs a successful send, remounts the page, and verifies the sent messages
remain visible.
🧹 Nitpick comments (4)
apps/api/src/infrastructure/db/schema.ts (1)

111-125: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider adding a database-level CHECK constraint for role.

Drizzle's text('role', { enum: [...] }) config only infers TypeScript types. It does not add a runtime or database-level constraint. Add a check() constraint on role so the database rejects invalid values, independent of the application layer.

♻️ Proposed fix to add a `CHECK` constraint
+import { check } from 'drizzle-orm/sqlite-core';
+import { sql } from 'drizzle-orm';
+
 export const message = sqliteTable(
   'Message',
   {
     id: text('id').primaryKey(),
     conversationId: text('conversationId')
       .notNull()
       .references(() => conversation.id, { onDelete: 'cascade' }),
     role: text('role', { enum: ['user', 'assistant'] }).notNull(),
     content: text('content').notNull(),
     createdAt: integer('createdAt', { mode: 'timestamp_ms' })
       .notNull()
       .$defaultFn(() => new Date()),
   },
-  (table) => [index('Message_conversationId_idx').on(table.conversationId)],
+  (table) => [
+    index('Message_conversationId_idx').on(table.conversationId),
+    check('Message_role_check', sql`${table.role} IN ('user', 'assistant')`),
+  ],
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/db/schema.ts` around lines 111 - 125, Update the
message table definition to add a database-level check constraint on the role
column, allowing only the existing 'user' and 'assistant' values. Use Drizzle’s
check constraint in the table configuration alongside the
Message_conversationId_idx definition, while preserving the current TypeScript
enum configuration.
apps/api/src/infrastructure/db/repositories/DrizzleMessageRepository.ts (1)

27-34: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider a limit or pagination for long conversation histories.

findAllByConversationId retrieves every message for a conversation with no limit. For long-running conversations, this grows unbounded and may need pagination, especially if this data feeds an LLM context window with token limits.

Add a limit/cursor parameter now, or track this as a follow-up once conversation length becomes a real concern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/db/repositories/DrizzleMessageRepository.ts`
around lines 27 - 34, Update findAllByConversationId to accept a limit and
pagination cursor, apply them to the ordered Drizzle query, and preserve
chronological results while bounding returned conversation history. Update its
interface and callers to pass the new parameters or use sensible defaults.
apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts (1)

56-62: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the ownership check.

ChatWithAssistantUseCase, GetChatHistoryUseCase and DeleteConversationUseCase repeat the same find-then-verify block. A shared helper, for example assertConversationOwnedBy(conversationId, userId), would keep the error codes consistent as more conversation use cases arrive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts` around lines 56 -
62, Extract the repeated conversation lookup and ownership validation from
ChatWithAssistantUseCase into a shared assertConversationOwnedBy helper, then
reuse it in GetChatHistoryUseCase and DeleteConversationUseCase. Preserve the
existing NOT_FOUND and FORBIDDEN error codes and return the validated
conversation for callers that need it.
apps/api/src/use-cases/conversations/ICreateConversationUseCase.ts (1)

3-5: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider an input object for consistency.

IDeleteConversationUseCase and IGetChatHistoryUseCase both accept a named input object. This contract and IListConversationsUseCase accept a bare userId string. A named object, for example { userId }, keeps the use-case surface uniform and prevents an accidental swap with conversationId at a call site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/conversations/ICreateConversationUseCase.ts` around
lines 3 - 5, Update ICreateConversationUseCase.execute to accept a named input
object containing userId instead of a bare string, and propagate this contract
change through its implementations and callers while preserving the existing
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts`:
- Around line 158-201: Update ChatWithAssistantUseCase.execute so the user
message is persisted via messageRepository.create before resolving the LLM
provider or invoking complete/completeWithTools. Preserve rate-limit behavior by
keeping the limiter check before persistence, and add test coverage verifying
the user message remains stored when provider creation or completion fails.

In `@apps/api/src/http/schema/queries/chatQueries.ts`:
- Around line 29-34: Update the chatHistory resolver around
getChatHistoryUseCase.execute to catch ownership and not-found failures, pass
them through the established fromCodedError mapping, and preserve the existing
messageMapper.toDTO mapping for successful results.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts`:
- Around line 90-95: The conversation timestamp is only updated during title
initialization, so later messages leave sidebar ordering stale. Add a touch(id)
method to IConversationRepository and implement it in
DrizzleConversationRepository to update the conversation’s updatedAt to the
current time, then call it from ChatWithAssistantUseCase.execute after the
message writes on every turn.
- Around line 75-88: Update ChatWithAssistantUseCase to persist the user message
before calling complete, while preserving the pre-write history.length === 0
value for first-turn title derivation. Use the provided transactionManager and
transaction-scoped client from getClient to atomically create the assistant
reply and update the conversation title; ensure failures in the provider call
still leave the user message persisted.
- Around line 67-73: Limit the history used to build messages in
ChatWithAssistantUseCase by selecting only the most recent N turns or an
appropriate token-bounded window from the ordered result, while preserving
chronological order for replay. Keep title-update logic based on the full
history length rather than the truncated window.

In `@apps/api/src/use-cases/conversations/CreateConversationUseCase.ts`:
- Around line 13-15: Update CreateConversationUseCase.execute to prevent
unbounded conversation creation: enforce a dedicated conversation rate limiter
before calling conversationRepository.create, or first reuse an existing empty
conversation for the user. Preserve the current generated-id creation path only
when no reusable conversation exists, and ensure the authenticated userId is
used for the limit or lookup.

In `@apps/web/src/__tests__/components/AssistantPage.test.tsx`:
- Around line 68-84: Extend the test around AssistantPage so that after the
successful send it verifies the ['chatHistory'] cache contains both the
submitted user message and the assistant response. Use the existing query client
or remount AssistantPage to confirm the persisted history, while preserving the
current request and rendered-message assertions.

In `@apps/web/src/routes/_authenticated/assistant.tsx`:
- Around line 81-91: Update the send and clear mutation controls around send,
clear, and onClear so sending is disabled while clear.isPending, clearing is
disabled while send.isPending, and onClear returns without acting when
send.isPending. Preserve the existing mutation behavior when neither request is
pending.
- Around line 62-65: Disable SSR for the protected Route configuration by
setting ssr to false, and move session initialization into beforeLoad using
hydrateSession() so it runs client-side before AssistantPage data loading.
Preserve the existing chatHistoryQueryOptions loader after the session has been
hydrated.

---

Outside diff comments:
In `@apps/web/src/routes/_authenticated/assistant.tsx`:
- Around line 109-116: The handleSend flow must also update the ['chatHistory']
query cache after send.mutateAsync succeeds, appending both the user message and
returned assistant message while preserving existing history. Add a regression
test that performs a successful send, remounts the page, and verifies the sent
messages remain visible.

---

Nitpick comments:
In `@apps/api/src/infrastructure/db/repositories/DrizzleMessageRepository.ts`:
- Around line 27-34: Update findAllByConversationId to accept a limit and
pagination cursor, apply them to the ordered Drizzle query, and preserve
chronological results while bounding returned conversation history. Update its
interface and callers to pass the new parameters or use sensible defaults.

In `@apps/api/src/infrastructure/db/schema.ts`:
- Around line 111-125: Update the message table definition to add a
database-level check constraint on the role column, allowing only the existing
'user' and 'assistant' values. Use Drizzle’s check constraint in the table
configuration alongside the Message_conversationId_idx definition, while
preserving the current TypeScript enum configuration.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts`:
- Around line 56-62: Extract the repeated conversation lookup and ownership
validation from ChatWithAssistantUseCase into a shared assertConversationOwnedBy
helper, then reuse it in GetChatHistoryUseCase and DeleteConversationUseCase.
Preserve the existing NOT_FOUND and FORBIDDEN error codes and return the
validated conversation for callers that need it.

In `@apps/api/src/use-cases/conversations/ICreateConversationUseCase.ts`:
- Around line 3-5: Update ICreateConversationUseCase.execute to accept a named
input object containing userId instead of a bare string, and propagate this
contract change through its implementations and callers while preserving the
existing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1bc52607-92de-460f-830c-b35ba1d18bda

📥 Commits

Reviewing files that changed from the base of the PR and between 7deb08a and 147fe5f.

📒 Files selected for processing (44)
  • apps/api/drizzle/0003_confused_satana.sql
  • apps/api/drizzle/meta/0003_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts
  • apps/api/src/__tests__/application/chat/GetChatHistoryUseCase.test.ts
  • apps/api/src/__tests__/application/conversations/CreateConversationUseCase.test.ts
  • apps/api/src/__tests__/application/conversations/DeleteConversationUseCase.test.ts
  • apps/api/src/__tests__/application/conversations/ListConversationsUseCase.test.ts
  • apps/api/src/__tests__/helpers/createTestDb.ts
  • apps/api/src/__tests__/helpers/mocks.ts
  • apps/api/src/__tests__/infrastructure/db/repositories/DrizzleConversationRepository.test.ts
  • apps/api/src/__tests__/infrastructure/db/repositories/DrizzleMessageRepository.test.ts
  • apps/api/src/__tests__/interface-adapters/mappers/ConversationMapper.test.ts
  • apps/api/src/__tests__/interface-adapters/mappers/MessageMapper.test.ts
  • apps/api/src/constants.ts
  • apps/api/src/domain/conversation/Conversation.ts
  • apps/api/src/domain/message/Message.ts
  • apps/api/src/http/container.ts
  • apps/api/src/http/schema/index.ts
  • apps/api/src/http/schema/mutations/chatMutations.ts
  • apps/api/src/http/schema/queries/chatQueries.ts
  • apps/api/src/http/schema/types/ConversationType.ts
  • apps/api/src/http/schema/types/MessageType.ts
  • apps/api/src/http/schema/types/inputs/ChatInputs.ts
  • apps/api/src/infrastructure/db/repositories/DrizzleConversationRepository.ts
  • apps/api/src/infrastructure/db/repositories/DrizzleMessageRepository.ts
  • apps/api/src/infrastructure/db/schema.ts
  • apps/api/src/interface-adapters/mappers/ConversationMapper.ts
  • apps/api/src/interface-adapters/mappers/MessageMapper.ts
  • apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts
  • apps/api/src/use-cases/chat/GetChatHistoryUseCase.ts
  • apps/api/src/use-cases/chat/IGetChatHistoryUseCase.ts
  • apps/api/src/use-cases/conversations/CreateConversationUseCase.ts
  • apps/api/src/use-cases/conversations/DeleteConversationUseCase.ts
  • apps/api/src/use-cases/conversations/ICreateConversationUseCase.ts
  • apps/api/src/use-cases/conversations/IDeleteConversationUseCase.ts
  • apps/api/src/use-cases/conversations/IListConversationsUseCase.ts
  • apps/api/src/use-cases/conversations/ListConversationsUseCase.ts
  • apps/api/src/use-cases/ports/IConversationRepository.ts
  • apps/api/src/use-cases/ports/IMessageRepository.ts
  • apps/web/src/__tests__/components/AssistantPage.test.tsx
  • apps/web/src/__tests__/setup.ts
  • apps/web/src/routeTree.gen.ts
  • apps/web/src/routes/_authenticated/assistant.tsx
💤 Files with no reviewable changes (1)
  • apps/api/src/http/schema/types/inputs/ChatInputs.ts

Comment on lines +158 to +201
it("persists the user's message and the assistant's reply after a successful response", async () => {
const llmProvider = makeToolCallingProvider({ content: 'Hello there!', toolCalls: [] });
const messageRepository = makeMessageRepository();
const generateId = vi.fn().mockReturnValueOnce('user-msg-id').mockReturnValueOnce('ai-msg-id');
const deps = makeDeps({
llmProviderFactory: makeLLMProviderFactory({
forUser: vi.fn().mockResolvedValue(llmProvider),
}),
messageRepository,
generateId,
});

await new ChatWithAssistantUseCase(deps as never).execute({
...baseInput,
message: 'hi',
});

expect(messageRepository.create).toHaveBeenNthCalledWith(1, {
id: 'user-msg-id',
conversationId: 'conv-1',
role: 'user',
content: 'hi',
});
expect(messageRepository.create).toHaveBeenNthCalledWith(2, {
id: 'ai-msg-id',
conversationId: 'conv-1',
role: 'assistant',
content: 'Hello there!',
});
});

it('does not persist anything when rate-limited', async () => {
const messageRepository = makeMessageRepository();
const deps = makeDeps({
chatRateLimiter: makeRateLimiter({ consume: vi.fn().mockReturnValue(false) }),
messageRepository,
});

await new ChatWithAssistantUseCase(deps as never)
.execute({ ...baseInput, message: 'hi' })
.catch(() => {});

expect(messageRepository.create).not.toHaveBeenCalled();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'ChatWithAssistantUseCase\.(ts|test\.ts)$|ChatWithAssistantUseCase' . | sed 's#^\./##'

echo "== test file outline/section =="
if [ -f apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts ]; then
  wc -l apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts
  ast-grep outline apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts 2>/dev/null || true
  sed -n '1,260p' apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts
fi

echo "== implementation search =="
rg -n "class ChatWithAssistantUseCase|execute\\(|messageRepository\\.create|complete\\(|completeWithTools|AI_NOT_CONFIGURED|llmProviderFactory" apps/api/src -g '*.ts'

Repository: mankatcheung/job-finder

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ChatWithAssistantUseCase implementation =="
sed -n '1,180p' apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts

echo "== helpers around makeToolCallingProvider and makeLLMProviderFactory =="
sed -n '1,70p' apps/api/src/__tests__/helpers/mocks.ts
sed -n '420,475p' apps/api/src/__tests__/helpers/mocks.ts

echo "== relevant test cases =="
sed -n '80,220p' apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts
sed -n '320,360p'

echo "== tests that call repository create in chat use-case tests =="
rg -n "messageRepository|conversationRepository|create|updateTitle|hasBeenNthCalledWith|not\\.toHaveBeenCalled" apps/api/src/__tests__/application/chat/ChatWithassistantUseCase.test.ts apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts 2>/dev/null || true

Repository: mankatcheung/job-finder

Length of output: 18105


Persist the user message before calling the LLM.

execute() calls complete() before messageRepository.create, so a rejected llmProviderFactory.forUser() call or completeWithTools() failure prevents the user’s input from being stored. Add coverage for this failure path, and move user-message persistence before complete() so transient LLM provider failures do not drop user input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts`
around lines 158 - 201, Update ChatWithAssistantUseCase.execute so the user
message is persisted via messageRepository.create before resolving the LLM
provider or invoking complete/completeWithTools. Preserve rate-limit behavior by
keeping the limiter check before persistence, and add test coverage verifying
the user message remains stored when provider creation or completion fails.

Comment on lines +29 to +34
const { getChatHistoryUseCase, messageMapper } = ctx.diScope.cradle;
const messages = await getChatHistoryUseCase.execute({
userId: ctx.user.sub,
conversationId: String(args.conversationId),
});
return messages.map((m) => messageMapper.toDTO(m));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Map use-case errors in chatHistory.

Ownership and not-found failures from getChatHistoryUseCase bypass fromCodedError. The resolver can then return an untyped internal GraphQL error instead of the established coded error response.

Proposed fix
 import { GraphQLError } from 'graphql';
+import { fromCodedError } from '`#src/http/errors/AppError.js`';
 
-      const messages = await getChatHistoryUseCase.execute({
-        userId: ctx.user.sub,
-        conversationId: String(args.conversationId),
-      });
-      return messages.map((m) => messageMapper.toDTO(m));
+      try {
+        const messages = await getChatHistoryUseCase.execute({
+          userId: ctx.user.sub,
+          conversationId: String(args.conversationId),
+        });
+        return messages.map((m) => messageMapper.toDTO(m));
+      } catch (err) {
+        throw fromCodedError(err);
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { getChatHistoryUseCase, messageMapper } = ctx.diScope.cradle;
const messages = await getChatHistoryUseCase.execute({
userId: ctx.user.sub,
conversationId: String(args.conversationId),
});
return messages.map((m) => messageMapper.toDTO(m));
import { GraphQLError } from 'graphql';
import { fromCodedError } from '`#src/http/errors/AppError.js`';
const { getChatHistoryUseCase, messageMapper } = ctx.diScope.cradle;
try {
const messages = await getChatHistoryUseCase.execute({
userId: ctx.user.sub,
conversationId: String(args.conversationId),
});
return messages.map((m) => messageMapper.toDTO(m));
} catch (err) {
throw fromCodedError(err);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/http/schema/queries/chatQueries.ts` around lines 29 - 34, Update
the chatHistory resolver around getChatHistoryUseCase.execute to catch ownership
and not-found failures, pass them through the established fromCodedError
mapping, and preserve the existing messageMapper.toDTO mapping for successful
results.

Comment on lines +67 to 73
const history = await this.deps.messageRepository.findAllByConversationId(input.conversationId);

const messages: LLMMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...input.history.map((m) => ({ role: m.role, content: m.content })),
...history.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content: input.message },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the message repository implementation and its ordering.
fd -t f 'DrizzleMessageRepository.ts' --exec cat -n
fd -t f 'IMessageRepository.ts' --exec cat -n

Repository: mankatcheung/job-finder

Length of output: 2254


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the use case around history construction and any relevant constants/config.
use_case_file="$(fd -t f '^ChatWithAssistantUseCase\.ts$' | head -n 1)"
echo "use_case_file=$use_case_file"
wc -l "$use_case_file"
cat -n "$use_case_file" | sed -n '1,140p'

echo "---- search constants ----"
rg -n "MAX_HISTORY|HISTORY|token|context|usage|input\.message|messageRepository\.findAllByConversationId" apps/api/src -S

Repository: mankatcheung/job-finder

Length of output: 50379


Cap the chat history before sending the request.

findAllByConversationId already returns rows ordered by createdAt, but ChatWithAssistantUseCase sends every stored turn on each request. Limit the replayed history, such as the last N turns or a token budget, before sending messages to the provider. Keep the title-update check on the full stored length if you apply a window.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts` around lines 67 -
73, Limit the history used to build messages in ChatWithAssistantUseCase by
selecting only the most recent N turns or an appropriate token-bounded window
from the ordered result, while preserving chronological order for replay. Keep
title-update logic based on the full history length rather than the truncated
window.

Comment on lines +75 to +88
const reply = await this.complete(messages, input.userId);

await this.deps.messageRepository.create({
id: this.deps.generateId(),
conversationId: input.conversationId,
role: 'user',
content: input.message,
});
await this.deps.messageRepository.create({
id: this.deps.generateId(),
conversationId: input.conversationId,
role: 'assistant',
content: reply,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Persist the user message before the provider call, and make the writes atomic.

Two defects exist in this sequence.

  1. The user message is written only after complete resolves. If the provider call fails, for example on a network error, a provider rate limit or AI_NOT_CONFIGURED, the user turn is never stored. The web client keeps it in optimistic state, so the message appears to be sent until the page reloads and it is gone.
  2. The two create calls and the updateTitle call are separate writes. A failure between them leaves a user message with no assistant reply, or a conversation without its derived title.

Write the user message first, then wrap the reply write and the title update in one transaction. The container already provides transactionManager, and getClient resolves the transaction-scoped client.

🐛 Proposed restructure
-    const reply = await this.complete(messages, input.userId);
-
-    await this.deps.messageRepository.create({
-      id: this.deps.generateId(),
-      conversationId: input.conversationId,
-      role: 'user',
-      content: input.message,
-    });
-    await this.deps.messageRepository.create({
-      id: this.deps.generateId(),
-      conversationId: input.conversationId,
-      role: 'assistant',
-      content: reply,
-    });
+    await this.deps.messageRepository.create({
+      id: this.deps.generateId(),
+      conversationId: input.conversationId,
+      role: 'user',
+      content: input.message,
+    });
+
+    const reply = await this.complete(messages, input.userId);
+
+    await this.deps.messageRepository.create({
+      id: this.deps.generateId(),
+      conversationId: input.conversationId,
+      role: 'assistant',
+      content: reply,
+    });

If you move the user write earlier, keep the history.length === 0 value that you captured before the write, so the title derivation on Line 90 still triggers on the first turn.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts` around lines 75 -
88, Update ChatWithAssistantUseCase to persist the user message before calling
complete, while preserving the pre-write history.length === 0 value for
first-turn title derivation. Use the provided transactionManager and
transaction-scoped client from getClient to atomically create the assistant
reply and update the conversation title; ensure failures in the provider call
still leave the user message persisted.

Comment on lines +90 to +95
if (history.length === 0) {
await this.deps.conversationRepository.updateTitle(
input.conversationId,
this.deriveTitle(input.message),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

updatedAt becomes stale after the first turn.

Conversation.updatedAt uses $onUpdate on the Conversation row, so it only advances when a column of that row changes. This block calls updateTitle only when the conversation was empty. Writing a Message row does not touch the parent. After the first turn, updatedAt never changes again.

IConversationRepository.findAllByUserId documents "Newest-updated first, for the conversation list/sidebar", and DrizzleConversationRepository.findAllByUserId orders by desc(conversation.updatedAt). The sidebar therefore stops reflecting recent activity, and every conversation keeps its creation order.

Add a repository method that touches the conversation on each message write, for example touch(id) setting updatedAt to the current time, and call it in execute after the message writes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts` around lines 90 -
95, The conversation timestamp is only updated during title initialization, so
later messages leave sidebar ordering stale. Add a touch(id) method to
IConversationRepository and implement it in DrizzleConversationRepository to
update the conversation’s updatedAt to the current time, then call it from
ChatWithAssistantUseCase.execute after the message writes on every turn.

Comment on lines +13 to +15
async execute(userId: string): Promise<Conversation> {
return this.deps.conversationRepository.create({ id: this.deps.generateId(), userId });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the conversation mutations and any rate limiting applied.
fd -t f 'chatMutations.ts' --exec cat -n
rg -nP 'createConversationUseCase|RateLimiter' apps/api/src/http -C2

Repository: mankatcheung/job-finder

Length of output: 8022


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== use case file =="
fd -t f 'CreateConversationUseCase.ts' --exec cat -n

echo "== rate limiter implementation =="
fd -t f 'RateLimiter.ts|IRateLimiter.ts' --exec cat -n

echo "== repo schema/rows relevant to conversations and messages =="
fd -t f 'Conversations.*|Messages.*' apps/api/src/infrastructure/db apps/api/src -g '*Conversation*' -g '*Message*' -g '*.ts' | sort | sed -n '1,120p'
rg -n "conversations|messages|createConversation" apps/api/src/infrastructure/db apps/api/src/use-cases apps/api/src/http -C 3 | sed -n '1,240p'

Repository: mankatcheung/job-finder

Length of output: 16514


Add a rate limit to createConversation.

The authenticated mutation calls createConversationUseCase.execute(ctx.user.sub) without consuming a rate limiter, while SendChatMessage already enforces chatRateLimiter. Add a conversation-creation limiter, or reuse an existing empty conversation before creating another row.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/conversations/CreateConversationUseCase.ts` around
lines 13 - 15, Update CreateConversationUseCase.execute to prevent unbounded
conversation creation: enforce a dedicated conversation rate limiter before
calling conversationRepository.create, or first reuse an existing empty
conversation for the user. Preserve the current generated-id creation path only
when no reusable conversation exists, and ensure the authenticated userId is
used for the limit or lookup.

Comment on lines +68 to +84
it('sends a message without a history argument and appends the reply', async () => {
mockGqlRequest.mockResolvedValue({ sendChatMessage: 'You have 2 active applications.' });
render(<AssistantPage />, { wrapper: Wrapper });

fireEvent.change(screen.getByPlaceholderText('Ask a question…'), {
target: { value: 'how many active applications do I have?' },
});
fireEvent.click(screen.getByRole('button', { name: 'Send' }));

await waitFor(() =>
expect(screen.getByText('You have 2 active applications.')).toBeInTheDocument(),
);
expect(screen.getByText('how many active applications do I have?')).toBeInTheDocument();
expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('sendChatMessage'), {
message: 'how many active applications do I have?',
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test chat-history cache persistence after sending.

This test verifies local state only. It does not verify the ['chatHistory'] cache after a successful send.

Assert the cache contents or remount AssistantPage after the mutation. This test must show both the user message and assistant response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/__tests__/components/AssistantPage.test.tsx` around lines 68 -
84, Extend the test around AssistantPage so that after the successful send it
verifies the ['chatHistory'] cache contains both the submitted user message and
the assistant response. Use the existing query client or remount AssistantPage
to confirm the persisted history, while preserving the current request and
rendered-message assertions.

Comment on lines 62 to 65
export const Route = createFileRoute('/_authenticated/assistant')({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(chatHistoryQueryOptions),
component: AssistantPage,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 3 'createFileRoute\(|ssr:\s*false|hydrateSession' apps/web/src/routes

Repository: mankatcheung/job-finder

Length of output: 18286


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== assistant route =="
cat -n apps/web/src/routes/_authenticated/assistant.tsx

echo
echo "== query client/graphql client relevant exports =="
rg -n -C 4 'export const queryClient|new QueryClient|export .*hydrateSession|function hydrateSession|const hydrateSession|from .*queryClient|from .*graphql/client' apps/web/src/lib apps/web/src/graphql apps/web/src -g '*.ts' -g '*.tsx' | sed -n '1,220p'

Repository: mankatcheung/job-finder

Length of output: 26541


Disable SSR for this protected route.

This loader calls gqlClient.request(...chatHistory...) before the session is established, and the API cookie is unavailable to the web server. Set ssr: false and resolve the session client-side through beforeLoad/hydrateSession() to avoid a server render failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/assistant.tsx` around lines 62 - 65,
Disable SSR for the protected Route configuration by setting ssr to false, and
move session initialization into beforeLoad using hydrateSession() so it runs
client-side before AssistantPage data loading. Preserve the existing
chatHistoryQueryOptions loader after the session has been hydrated.

Source: Coding guidelines

Comment thread apps/web/src/routes/_authenticated/assistant.tsx Outdated
Frontend half of the backend commit above. assistant.tsx now shows a
conversation sidebar instead of a single fixed thread:

- Route search param (?conversation=id) is the source of truth for
  which conversation is active, not local component state — so
  switching conversations, refreshing, and back/forward navigation
  all just work via the URL. loaderDeps + loader prefetch both the
  conversations list and (if a conversation is already selected) its
  history, same ensureQueryData pattern as JEF-64.
- No local `messages` state at all: each conversation's history lives
  under its own `['chatHistory', conversationId]` query key, so
  switching conversations is just React Query switching which cached
  query is active — no manual seeding/resetting needed between
  conversations like a single useState would require.
- "New conversation" clears the active selection (a client-side draft
  state, no API call) rather than eagerly creating a row — a
  conversation is actually created lazily on first send, so clicking
  "New" repeatedly without typing anything doesn't litter the sidebar
  with empty untitled conversations.
- Sending with no active conversation: creates one first, optimistic-
  updates it into the sidebar list, navigates the URL to it, then
  sends into it — implicit-creation UX matching the backend's
  lazy-title-on-first-message behavior.
- Delete (replacing the old single "Clear" button) is now per-
  conversation, exposed as a small trash icon on each sidebar row
  (aria-label="Delete conversation" — icon-only, needed a real
  accessible name, not just a test hook).

Verified: typecheck/lint/build clean. 155/155 web tests passing
(AssistantPage.test.tsx rewritten for the new architecture — mocks
route search/navigate instead of the old getQueryData-seeding pattern,
since messages are now query-driven rather than locally seeded).
Manually verified end-to-end against a live dev server + real SQLite
DB: seeded two conversations with distinct histories directly in the
DB, then confirmed via Playwright that the sidebar lists both
newest-first, switching between them shows the correct isolated
history for each (not a mix), and deleting the active one removes it
from the sidebar, resets to the empty/compose state, and — confirmed
via a page reload — the delete persisted server-side rather than only
updating local UI state.

Ref: JEF-65
…h main

Both this branch and the just-merged JEF-53 (refresh token rotation)
independently generated a migration numbered 0003. Renumbering this
branch's to 0004 so it applies after JEF-53's Session-table changes
once merged with main. The 0003 snapshot is dropped here and will be
regenerated fresh against main's actual 0003 baseline after merging,
so drizzle-kit computes the correct diff instead of a stale one.
…history

# Conflicts:
#	apps/api/drizzle/meta/_journal.json
drizzle-kit generate (run to produce the missing 0004 snapshot after
the merge) correctly detected no snapshot existed yet for 0004 and
produced a *new* 0005 migration + snapshot instead — since it only had
0003's snapshot to diff against, it recomputed the Conversation/Message
CREATE TABLE statements from scratch rather than seeing them as already
covered by 0004. That regenerated SQL was byte-identical to 0004's, so
0005 was a straight duplicate.

Kept 0004's migration file (already correct), relabeled the newly
generated snapshot as 0004's own (its prevId already correctly chains
from main's 0003 snapshot), and dropped the redundant 0005 migration
+ journal entry. Chain is now 0000→0004 with a snapshot for every step.
…history

# Conflicts:
#	apps/api/drizzle/meta/_journal.json
@mankatcheung
mankatcheung merged commit 8d20c5b into main Aug 1, 2026
15 of 16 checks passed
@mankatcheung
mankatcheung deleted the worktree-jef-65-chat-history branch August 2, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant