nexus/.planning/phases/21-chat-foundation/21-RESEARCH.md
Mikkel Georgsen 6c4272ce85 [nexus] chore: migrate .planning/ from agent repo to nexus repo
Planning artifacts (milestones v1.0-v1.2.1, v1.3 queue, PROJECT.md,
STATE.md, config) now live alongside the code they describe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:42 +00:00

29 KiB
Raw Blame History

Phase 21: Chat Foundation - Research

Researched: 2026-04-01 Domain: Persistent chat UI with markdown rendering, DB schema, and theme-aware code highlighting Confidence: HIGH

Summary

Phase 21 establishes the structural foundation for all subsequent chat phases: database tables for conversations and messages, a REST API for CRUD operations on those tables, and the React UI layer (chat drawer + sidebar conversation list + message renderer). It does NOT include agent execution or streaming — those land in Phase 22. The entire phase is UI-plus-persistence: create a conversation, post static messages, render them with full markdown fidelity, and reload without data loss.

The codebase already contains every necessary supporting primitive. The database layer uses Drizzle ORM with PostgreSQL (not libSQL — the PRD used that term loosely; the running system is PostgreSQL 17). The UI already has MarkdownBody (ui/src/components/MarkdownBody.tsx) using react-markdown + remark-gfm + mermaid, but without syntax highlighting for code blocks — that gap must be closed here (CHAT-02/03). The PropertiesPanel / PanelContext pattern demonstrates exactly how a right-side drawer should be wired. Theme integration requires no new plumbing; useTheme() + THEME_META is already the authoritative system.

Primary recommendation: Add two new Drizzle schema files (chat_conversations + chat_messages), generate and run a migration, create service+route files following the existing factory pattern, and add a ChatPanel component that re-uses PanelContext open/close state (or a new dedicated ChatPanelContext keyed to localStorage).

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

None — discuss phase was skipped per user setting (workflow.skip_discuss: true).

Claude's Discretion

All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.

Deferred Ideas (OUT OF SCOPE)

None — discuss phase skipped. </user_constraints>

<phase_requirements>

Phase Requirements

Per ROADMAP.md (authoritative, overrides the broader CHAT-01..11 list in the task prompt):

ID Description Research Support
CHAT-02 Markdown rendering: code blocks with syntax highlighting, tables, lists, headings, links, images Existing MarkdownBody covers most; syntax highlighting needs rehype-highlight or react-syntax-highlighter added
CHAT-03 Code blocks: one-click copy button and language label Custom pre/code component override in MarkdownBody extensions
CHAT-04 Multiple concurrent conversations: sidebar shows full list chat_conversations table + /api/companies/:id/conversations GET endpoint + sidebar React component
CHAT-05 Conversation titles: auto-generated from first message, manually editable title column on chat_conversations; auto-generated server-side on first message insert; PATCH endpoint
CHAT-06 Delete, archive, pin conversations deletedAt, archivedAt, pinnedAt nullable timestamps on chat_conversations
INPUT-01 Multi-line input with auto-resize: grows with content up to max height <textarea> with CSS field-sizing: content or rows auto-expand hook
INPUT-07 Keyboard shortcuts: Enter to send, Shift+Enter for newline, Escape to cancel onKeyDown handler on textarea
HIST-01 All conversations persisted in PostgreSQL (codebase uses PG, not libSQL) Two new Drizzle schema files + migration
HIST-02 Conversation list in sidebar: sorted by most recent, searchable, filterable by agent Server-side sort by updatedAt DESC; client-side filter/search on loaded list
HIST-03 Infinite scroll in conversation list sidebar TanStack Query useInfiniteQuery with cursor pagination
HIST-05 Cross-device sync: conversations accessible from any device on network Covered by server API — no extra work; Nexus is already a server
HIST-06 Chat history survives server restarts: no in-memory-only state Covered by DB persistence; no in-memory chat state
THEME-01 Chat interface respects Nexus theme system Reuse useTheme() + CSS variables already in index.css
THEME-02 Code blocks use theme-appropriate syntax highlighting Pass THEME_META[theme].dark to syntax highlighter; use Catppuccin/Tokyo Night themes
</phase_requirements>

Project Constraints (from CLAUDE.md)

Constraint Detail
Upstream sync Display-layer changes only. DB schema, API routes, code identifiers, token formats must be upstream-compatible. New tables are additive and safe.
No data migration No changes to existing tables. New tables only — no column changes to existing schema.
Deploy target Mac Mini M4, local_trusted mode, single user
Language TypeScript (ESM) everywhere. No plain JS.
Package manager pnpm 9.15.4. Use pnpm add — never npm install.
Framework Express 5.1.0 routes must follow function fooRoutes(db: Db): Router factory pattern
DB Drizzle ORM with PostgreSQL. Generate migration with pnpm db:generate then commit migration SQL.
Auth local_trusted mode means assertBoard(req) is the only auth gate needed
Testing Vitest (server) + React Testing Library (UI). Service tests use vi.mock pattern shown in activity-routes.test.ts.

Standard Stack

Core (already in project, no install needed)

Library Version Purpose Notes
drizzle-orm ^0.38.4 Schema definition + query builder Use existing pattern from documents.ts
react ^19.0.0 UI component layer
react-markdown ^10.1.0 Already in ui/package.json Basis of MarkdownBody
remark-gfm ^4.0.1 GFM tables/lists/strikethrough Already used in MarkdownBody
@tanstack/react-query ^5.x Server state, pagination useInfiniteQuery for conversation list
lucide-react ^0.574.0 Icons (MessageSquare, Pin, Archive, Trash2, Plus, etc.)
tailwind-merge / clsx current Conditional classNames

Additions required

Library Version Purpose Install
rehype-highlight 7.0.2 (current) Syntax highlighting via highlight.js in react-markdown pnpm --filter @paperclipai/ui add rehype-highlight highlight.js
highlight.js — (peer of rehype-highlight) Highlight.js core — provides Catppuccin/Tokyo Night themes pulled in by rehype-highlight

Why rehype-highlight over react-syntax-highlighter:

  • rehype-highlight integrates cleanly with react-markdown via the rehypePlugins prop — no custom component overrides needed per language
  • Highlight.js ships Catppuccin Mocha, Catppuccin Latte, and Tokyo Night CSS themes natively (as of hljs 11.x), avoiding custom CSS
  • Smaller bundle than react-syntax-highlighter which bundles Prism + all languages
  • Confidence: HIGH — verified against react-markdown docs and highlight.js theme list

Alternatives considered:

Instead of Could use Tradeoff
rehype-highlight react-syntax-highlighter RSH provides per-component control but requires a custom code component wrapper; more bundle weight
rehype-highlight shiki (via rehype-shiki) Shiki produces beautiful output but is heavier (WASM), more complex config, and overkill for this phase

Version verification:

npm view rehype-highlight version   # 7.0.2
npm view highlight.js version       # 11.x (pulled as transitive dep)
npm view react-markdown version     # 10.1.0 (already installed)

Installation (additions only):

pnpm --filter @paperclipai/ui add rehype-highlight
# highlight.js is a dependency of rehype-highlight — comes automatically

Architecture Patterns

packages/db/src/schema/
├── chat_conversations.ts   # new — conversation records
└── chat_messages.ts        # new — message records

packages/db/src/migrations/
└── 0047_chat_foundation.sql  # generated by drizzle-kit generate

packages/shared/src/
├── types/chat.ts           # new — ChatConversation, ChatMessage types
└── validators/chat.ts      # new — Zod schemas for create/update

server/src/
├── services/chat.ts        # new — chatService(db) factory
└── routes/chat.ts          # new — chatRoutes(db): Router

ui/src/
├── api/chat.ts             # new — chatApi fetch wrappers
├── context/ChatPanelContext.tsx  # new — open/closed + active conversation
├── components/
│   ├── ChatPanel.tsx        # new — right-side drawer shell
│   ├── ChatConversationList.tsx  # new — sidebar list with infinite scroll
│   ├── ChatMessageList.tsx  # new — message thread
│   ├── ChatInput.tsx        # new — auto-resize textarea
│   └── ChatMarkdownMessage.tsx   # new — MarkdownBody extended with rehype-highlight
└── hooks/
    └── useChatConversations.ts  # new — TanStack Query wrappers

Pattern 1: Drizzle Schema (follow existing pattern)

// Source: packages/db/src/schema/documents.ts (existing reference)
// packages/db/src/schema/chat_conversations.ts
import { pgTable, uuid, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";

export const chatConversations = pgTable(
  "chat_conversations",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    companyId: uuid("company_id").notNull().references(() => companies.id),
    title: text("title"),
    agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }),
    pinnedAt: timestamp("pinned_at", { withTimezone: true }),
    archivedAt: timestamp("archived_at", { withTimezone: true }),
    deletedAt: timestamp("deleted_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => ({
    companyUpdatedIdx: index("chat_conversations_company_updated_idx")
      .on(table.companyId, table.updatedAt),
    companyDeletedIdx: index("chat_conversations_company_deleted_idx")
      .on(table.companyId, table.deletedAt),
  }),
);
// packages/db/src/schema/chat_messages.ts
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { chatConversations } from "./chat_conversations.js";

export const chatMessages = pgTable(
  "chat_messages",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    conversationId: uuid("conversation_id").notNull()
      .references(() => chatConversations.id, { onDelete: "cascade" }),
    role: text("role").notNull(), // "user" | "assistant" | "system"
    content: text("content").notNull(),
    agentId: uuid("agent_id"), // which agent produced this (null for user messages)
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => ({
    conversationCreatedIdx: index("chat_messages_conversation_created_idx")
      .on(table.conversationId, table.createdAt),
  }),
);

Important: After creating schema files, export them from packages/db/src/schema/index.ts and run:

pnpm db:generate   # generates SQL migration under packages/db/src/migrations/
pnpm db:migrate    # applies migration to running DB

Pattern 2: Service Factory (follow existing pattern)

// Source: server/src/services/documents.ts (existing reference)
// server/src/services/chat.ts
import type { Db } from "@paperclipai/db";
import { chatConversations, chatMessages } from "@paperclipai/db";
import { and, desc, eq, isNull, lt } from "drizzle-orm";

export function chatService(db: Db) {
  return {
    async listConversations(companyId: string, opts: { cursor?: string; limit?: number }) {
      const limit = Math.min(opts.limit ?? 30, 100);
      const rows = await db
        .select()
        .from(chatConversations)
        .where(
          and(
            eq(chatConversations.companyId, companyId),
            isNull(chatConversations.deletedAt),
            opts.cursor
              ? lt(chatConversations.updatedAt, new Date(opts.cursor))
              : undefined,
          ),
        )
        .orderBy(desc(chatConversations.updatedAt))
        .limit(limit + 1);
      const hasMore = rows.length > limit;
      return { items: rows.slice(0, limit), hasMore };
    },
    // createConversation, getConversation, updateConversation, softDeleteConversation,
    // listMessages, addMessage, etc.
  };
}

Pattern 3: Route Factory (follow existing pattern)

// Source: server/src/routes/activity.ts (existing reference)
// server/src/routes/chat.ts
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { chatService } from "../services/chat.js";
import { validate } from "../middleware/validate.js";

export function chatRoutes(db: Db) {
  const router = Router();
  const svc = chatService(db);

  // GET  /api/companies/:companyId/conversations
  // POST /api/companies/:companyId/conversations
  // GET  /api/conversations/:id
  // PATCH /api/conversations/:id
  // DELETE /api/conversations/:id
  // GET  /api/conversations/:id/messages
  // POST /api/conversations/:id/messages
  return router;
}

Mount in server/src/app.ts following the existing route registration list.

Pattern 4: Chat Panel Context (localStorage-persisted open state)

// ui/src/context/ChatPanelContext.tsx
// Mirrors PanelContext.tsx pattern but keyed specifically to chat.
// Key: "nexus:chat-panel-open" (use nexus: prefix not paperclip: to stay in Nexus scope)

The chat panel is a separate right-side drawer from PropertiesPanel. They should not share state. The chat icon in Layout (or CompanyRail) toggles chatPanelOpen. The drawer sits between <main> and <PropertiesPanel> in the flex row, or on top of it as an overlay — implementation choice.

Recommended: Fixed-width right drawer (320400px) inside the existing flex row, hidden with w-0 overflow-hidden when closed, using the same CSS transition pattern as the sidebar (transition-[width] duration-100 ease-out).

Pattern 5: Infinite Scroll with TanStack Query

// ui/src/hooks/useChatConversations.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { chatApi } from "../api/chat";

export function useChatConversations(companyId: string) {
  return useInfiniteQuery({
    queryKey: ["chat", "conversations", companyId],
    queryFn: ({ pageParam }) =>
      chatApi.listConversations(companyId, { cursor: pageParam as string | undefined }),
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
    initialPageParam: undefined,
  });
}

Pattern 6: Theme-aware Syntax Highlighting

// ui/src/components/ChatMarkdownMessage.tsx
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { useTheme, THEME_META } from "../context/ThemeContext";
// Import highlight.js theme CSS dynamically based on active theme:
// catppuccin-mocha → "highlight.js/styles/base16/catppuccin.css" (dark variant)
// tokyo-night      → "highlight.js/styles/tokyo-night-dark.css"
// catppuccin-latte → "highlight.js/styles/base16/catppuccin.css" (light variant)

Code block copy button pattern: Override the pre component in react-markdown's components prop. Extract the code text from the child <code> element, wire a useClipboard/navigator.clipboard.writeText handler.

Pattern 7: Auto-resize Textarea

// ui/src/components/ChatInput.tsx
// Modern CSS approach: field-sizing: content (Chrome 123+, Firefox 129+)
// Fallback: ref.current.style.height = 'auto'; ref.current.style.height = ref.current.scrollHeight + 'px'
// onKeyDown: e.key === 'Enter' && !e.shiftKey → submit; e.key === 'Escape' → clear

Anti-Patterns to Avoid

  • Reusing PropertiesPanel for chat: The PropertiesPanel is content-dependent (varies per page). Chat is a persistent global panel. Use a separate context.
  • Storing conversation open/closed state in URL: Use localStorage (as PanelContext does). URL state would cause issues on navigation.
  • Using libSQL/Turso: The REQUIREMENTS.md says "libSQL" but the codebase runs PostgreSQL. Ignore the libSQL reference — it is a PRD artifact. Use the existing Drizzle/PG stack.
  • Hand-rolling markdown rendering: MarkdownBody already exists and handles mermaid, GFM, and mention chips. Extend it rather than create a parallel implementation.
  • Inline highlight.js CSS: Load theme CSS via a dynamic <link> or via CSS @import conditioned on .dark / .theme-tokyo-night — do not inline all themes at once.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Markdown rendering Custom parser react-markdown + remark-gfm Already in codebase, battle-tested
Syntax highlighting Token-by-token highlighter rehype-highlight (highlight.js) Covers 190+ languages, ships Catppuccin/Tokyo Night themes
Server state / pagination Custom fetch + cursor state useInfiniteQuery (TanStack Query) Already used everywhere in the project
Auto-resize textarea setInterval height checks CSS field-sizing: content + scroll height fallback One-liner with good browser support
Right-side drawer animation Custom JS animation CSS transition-[width] (same as sidebar) Already proven in Layout.tsx
Copy to clipboard Cross-browser clipboard shim navigator.clipboard.writeText Sufficient for local trusted mode

Common Pitfalls

Pitfall 1: highlight.js theme CSS loading in Vite

What goes wrong: Importing CSS from node_modules/highlight.js/styles/ at the module level causes all three themes to load simultaneously, causing visual conflicts. Why it happens: CSS imports in ESM/Vite are side-effecting; theme CSS from hljs uses global selectors (.hljs { ... }). How to avoid: Use a single CSS file that overrides hljs variables per theme class using your existing CSS variable system (.dark .hljs { ... }, .theme-tokyo-night .hljs { ... }), OR dynamically insert/swap a <link> element when theme changes. Warning signs: Code blocks always show one theme regardless of active theme switch.

Pitfall 2: chat_messages cascade delete gap

What goes wrong: Deleting a conversation (hard delete) leaves orphaned messages. Why it happens: Forgetting to set { onDelete: "cascade" } on the FK. How to avoid: Schema above already includes cascade; verify the generated SQL includes ON DELETE CASCADE before committing the migration.

Pitfall 3: updatedAt on conversation not bumped on new message

What goes wrong: The conversation list sort by updatedAt DESC shows stale ordering after a new message is posted. Why it happens: Drizzle auto-sets updatedAt only on direct row updates, not cascading through FK children. How to avoid: In chatService.addMessage(), also run an UPDATE chat_conversations SET updated_at = now() WHERE id = $conversationId.

Pitfall 4: PropertiesPanel hidden when chat panel is open

What goes wrong: Both panels try to occupy the right side of the layout; one hides the other. Why it happens: Both are flex-shrink-0 elements in the same row. How to avoid: The chat drawer should be a sibling of PropertiesPanel in the layout flex row. When the chat panel is open, PropertiesPanel should close (or they should coexist with a combined max-width). Decide at plan time — research suggests coexistence adds complexity; close PropertiesPanel when chat opens.

Pitfall 5: Auto-title generation on first message

What goes wrong: Title is set to a truncated version of the first user message, but the update never fires. Why it happens: The title is set conditionally only when the conversation has no title yet. Race condition if client retries the request. How to avoid: Use WHERE title IS NULL in the UPDATE to make the title set idempotent.

Pitfall 6: Conversation list flicker on useInfiniteQuery

What goes wrong: List flashes empty on first render before data loads. Why it happens: Default TanStack Query behavior shows isLoading: true on mount. How to avoid: Use placeholderData: keepPreviousData or show a skeleton list while loading.


Code Examples

Verified: Route factory pattern

// Source: server/src/routes/activity.ts (exists in codebase)
export function activityRoutes(db: Db): Router {
  const router = Router();
  const svc = activityService(db);
  router.get("/companies/:companyId/activity", async (req, res) => {
    assertBoard(req);
    assertCompanyAccess(req, req.params.companyId!);
    const result = await svc.list(req.params.companyId!);
    res.json(result);
  });
  return router;
}

Verified: react-markdown + rehype plugin composition

// Source: MarkdownBody.tsx (extended pattern)
import rehypeHighlight from "rehype-highlight";
<Markdown
  remarkPlugins={[remarkGfm]}
  rehypePlugins={[rehypeHighlight]}
  components={components}
>
  {content}
</Markdown>

Verified: Infinite scroll with TanStack Query v5

// Source: TanStack Query v5 docs — useInfiniteQuery API
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ["conversations", companyId],
  queryFn: ({ pageParam }) => chatApi.listConversations(companyId, { cursor: pageParam }),
  initialPageParam: undefined as string | undefined,
  getNextPageParam: (lastPage) =>
    lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
});

Verified: CSS transition pattern for drawer (from Layout.tsx)

// Same transition as sidebar — proven in production
<div
  className="overflow-hidden transition-[width] duration-100 ease-out"
  style={{ width: chatOpen ? 380 : 0 }}
>
  {/* panel content — will be hidden via width:0 overflow:hidden */}
</div>

State of the Art

Old Approach Current Approach Impact
react-syntax-highlighter (Prism) rehype-highlight (hljs) via rehype plugins Smaller bundle, cleaner react-markdown integration, native theme CSS
Custom infinite scroll with IntersectionObserver useInfiniteQuery with cursor Less code, built-in refetch/stale handling
CSS modules for code block themes CSS custom properties per .dark class Theme switch without JS style injection

Environment Availability

Dependency Required By Available Version Fallback
PostgreSQL (embedded) DB persistence Yes (embedded-postgres) 17-alpine
pnpm Package install Yes 9.15.4
Node.js Runtime Yes v25.8.2
rehype-highlight Syntax highlighting Not installed (needs pnpm add) 7.0.2 Fallback: unstyled code blocks (degrade gracefully)

Missing dependencies with no fallback:

  • None that block execution

Missing dependencies that need install:

  • rehype-highlight — install before implementing ChatMarkdownMessage.tsx

Validation Architecture

Test Framework

Property Value
Framework Vitest 3.x
Config file server/vitest.config.ts (server), vitest.config.ts (root, multi-project)
Quick run command pnpm vitest run server/src/__tests__/chat-service.test.ts
Full suite command pnpm test:run

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
HIST-01 Conversations and messages persisted to DB Unit (service) pnpm vitest run server/src/__tests__/chat-service.test.ts Wave 0
HIST-01 POST conversation creates DB row; GET returns it Integration (route) pnpm vitest run server/src/__tests__/chat-routes.test.ts Wave 0
CHAT-04 List conversations returns all for company, sorted by updatedAt Unit (service) above Wave 0
CHAT-05 First message auto-sets title on conversation Unit (service) above Wave 0
CHAT-06 Soft-delete / archive / pin set correct timestamps Unit (service) above Wave 0
CHAT-02/03 MarkdownBody renders code blocks with syntax highlight Component (UI) pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx Wave 0
INPUT-07 Enter sends, Shift+Enter inserts newline, Escape clears Component (UI) pnpm vitest run ui/src/components/ChatInput.test.tsx Wave 0
THEME-01/02 Theme CSS variables apply to chat panel and code blocks Manual visual Manual only
HIST-03 Infinite scroll loads next page on scroll to bottom Integration (UI) Manual only — requires browser scroll

Sampling Rate

  • Per task commit: pnpm vitest run server/src/__tests__/chat-service.test.ts
  • Per wave merge: pnpm test:run
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • server/src/__tests__/chat-service.test.ts — covers HIST-01, CHAT-04, CHAT-05, CHAT-06
  • server/src/__tests__/chat-routes.test.ts — covers route-level integration (POST conversation, GET list, POST message)
  • ui/src/components/ChatMarkdownMessage.test.tsx — covers CHAT-02/03 (code block render + copy button)
  • ui/src/components/ChatInput.test.tsx — covers INPUT-07 keyboard shortcuts

Open Questions

  1. Chat panel vs PropertiesPanel coexistence

    • What we know: Both are right-side panels in the same flex row in Layout.tsx
    • What's unclear: Should they coexist (both visible side-by-side) or should opening chat close the properties panel?
    • Recommendation: Close PropertiesPanel when chat opens (simpler, avoids cramped UI at default 1280px width)
  2. chat_conversations.agentId — required or optional at creation time?

    • What we know: Phase 22 adds the agent selector mid-conversation. Phase 21 has no streaming.
    • What's unclear: Do we need an agent association before streaming exists?
    • Recommendation: Make agentId nullable. Allow conversations to be created without a linked agent. The column is available for Phase 22 to use.
  3. Auto-generated title: server-side or client-side?

    • What we know: Client sends the first message; the title should derive from that message's first N characters.
    • Recommendation: Server-side, in chatService.addMessage() — if conversation.title IS NULL AND this is the first message, set title = truncate(content, 60). Avoids a client round-trip.

Sources

Primary (HIGH confidence)

  • ui/src/components/MarkdownBody.tsx — existing markdown component confirming react-markdown + remark-gfm usage
  • ui/src/context/PanelContext.tsx — panel open/close localStorage pattern
  • ui/src/components/Layout.tsx — layout structure, flex row, CSS transition pattern
  • packages/db/src/schema/documents.ts — Drizzle schema reference pattern
  • server/src/routes/activity.ts — route factory pattern
  • server/src/services/live-events.ts — service file pattern
  • packages/db/src/client.ts — database client (PostgreSQL, not libSQL)
  • ui/src/context/ThemeContext.tsx — Theme type, THEME_META, useTheme()
  • ui/src/index.css — CSS variable definitions for all three themes
  • ui/package.json — confirmed react-markdown@^10.1.0, remark-gfm@^4.0.1 already installed

Secondary (MEDIUM confidence)

  • npm view rehype-highlight version → 7.0.2 (verified at research time, 2026-04-01)
  • TanStack Query v5 useInfiniteQuery API (CLAUDE.md confirms @tanstack/react-query ^5.x)

Tertiary (LOW confidence)

  • highlight.js Catppuccin/Tokyo Night theme availability — assumed based on hljs 11.x changelog; verify exact CSS path at install time

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all existing dependencies verified in source; only one new package (rehype-highlight) needed
  • Architecture: HIGH — patterns confirmed by reading existing service/route/context files; direct analogy to existing documents domain
  • Pitfalls: MEDIUM — identified from code inspection; cascade delete and updatedAt bump are logic traps not yet observable in running code
  • Theme integration: HIGH — THEME_META, ThemeContext, and CSS variables fully examined

Research date: 2026-04-01 Valid until: 2026-05-01 (stable ecosystem; react-markdown and TanStack Query are very stable)