From 645535195bf2392229cf541dacd6dff06f0db834 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 18:05:51 +0000 Subject: [PATCH] feat(22-00): DB migration, shared types, react-virtual, agent-role-colors, CSS animation - Add updatedAt column to chat_messages schema (nullable) - Create migration 0048_add_chat_messages_updated_at.sql - Add updatedAt: string | null to ChatMessage shared type - Install @tanstack/react-virtual in ui workspace - Create agent-role-colors.ts with 11 distinct themed roles (THEME-03) - Add agent-role-colors.test.ts (4 tests all passing) - Add cursor-blink CSS animation with prefers-reduced-motion guard --- .../0048_add_chat_messages_updated_at.sql | 1 + packages/db/src/schema/chat_messages.ts | 1 + packages/shared/src/types/chat.ts | 1 + pnpm-lock.yaml | 75 +++++++++++++++++++ ui/package.json | 3 +- ui/src/index.css | 16 ++++ ui/src/lib/agent-role-colors.test.ts | 28 +++++++ ui/src/lib/agent-role-colors.ts | 17 +++++ 8 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/migrations/0048_add_chat_messages_updated_at.sql create mode 100644 ui/src/lib/agent-role-colors.test.ts create mode 100644 ui/src/lib/agent-role-colors.ts diff --git a/packages/db/src/migrations/0048_add_chat_messages_updated_at.sql b/packages/db/src/migrations/0048_add_chat_messages_updated_at.sql new file mode 100644 index 00000000..c9612667 --- /dev/null +++ b/packages/db/src/migrations/0048_add_chat_messages_updated_at.sql @@ -0,0 +1 @@ +ALTER TABLE "chat_messages" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now(); diff --git a/packages/db/src/schema/chat_messages.ts b/packages/db/src/schema/chat_messages.ts index d68a0846..0768813d 100644 --- a/packages/db/src/schema/chat_messages.ts +++ b/packages/db/src/schema/chat_messages.ts @@ -11,6 +11,7 @@ export const chatMessages = pgTable( content: text("content").notNull(), agentId: uuid("agent_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), }, (table) => ({ conversationCreatedIdx: index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt), diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index 6bb6bba2..a306773e 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -28,6 +28,7 @@ export interface ChatMessage { content: string; agentId: string | null; createdAt: string; + updatedAt: string | null; } export interface ChatConversationListResponse { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9469a435..1d196cdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -645,6 +645,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) + '@tanstack/react-virtual': + specifier: ^3.13.23 + version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -684,6 +687,9 @@ importers: react-router-dom: specifier: ^7.1.5 version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -3339,6 +3345,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.23': + resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4570,9 +4585,15 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -4583,6 +4604,10 @@ packages: resolution: {integrity: sha512-9D4SrmMXm4AhOZ08lnlGCZBzwfRnGjzZjkvPlkRfoPTODM2YeIAaEk+zO0vInh8DI4Qr3ySN8hLFxV1eKRYFaA==} engines: {node: '>=20.0.0'} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4768,6 +4793,7 @@ packages: libsql@0.5.29: resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lightningcss-android-arm64@1.30.2: @@ -4853,6 +4879,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@11.2.7: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} @@ -5484,6 +5513,9 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -5821,6 +5853,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -9264,6 +9299,14 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 + '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/virtual-core@3.13.23': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -10553,6 +10596,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -10573,6 +10620,13 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -10584,6 +10638,8 @@ snapshots: '@paperclipai/adapter-utils': 2026.325.0 picocolors: 1.1.1 + highlight.js@11.11.1: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) @@ -10826,6 +10882,12 @@ snapshots: loupe@3.2.1: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@11.2.7: {} lru-cache@5.1.1: @@ -11816,6 +11878,14 @@ snapshots: real-require@0.2.0: {} + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -12228,6 +12298,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 diff --git a/ui/package.json b/ui/package.json index f40e6188..91a4ded3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -41,14 +41,15 @@ "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/branding": "workspace:*", "@paperclipai/shared": "workspace:*", - "hermes-paperclip-adapter": "^0.2.0", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", + "@tanstack/react-virtual": "^3.13.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "diff": "^8.0.4", + "hermes-paperclip-adapter": "^0.2.0", "lexical": "0.35.0", "lucide-react": "^0.574.0", "mermaid": "^11.12.0", diff --git a/ui/src/index.css b/ui/src/index.css index c2cf4852..92c52b86 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -885,3 +885,19 @@ a.paperclip-project-mention-chip { [class*="_toolbarNodeKindSelectContainer_"] { z-index: 81 !important; } + +@keyframes cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.animate-cursor-blink { + animation: cursor-blink 800ms step-start infinite; +} + +@media (prefers-reduced-motion: reduce) { + .animate-cursor-blink { + animation: none; + opacity: 1; + } +} diff --git a/ui/src/lib/agent-role-colors.test.ts b/ui/src/lib/agent-role-colors.test.ts new file mode 100644 index 00000000..e07222ea --- /dev/null +++ b/ui/src/lib/agent-role-colors.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { AGENT_ROLES } from "@paperclipai/shared"; +import { agentRoleColors, agentRoleColorDefault } from "./agent-role-colors"; + +describe("agentRoleColors", () => { + it("has an entry for every AGENT_ROLES value", () => { + for (const role of AGENT_ROLES) { + expect(agentRoleColors[role]).toBeDefined(); + expect(agentRoleColors[role]).toContain("text-"); + } + }); + + it("each entry has both light and dark variant", () => { + for (const role of AGENT_ROLES) { + expect(agentRoleColors[role]).toContain("dark:"); + } + }); + + it("exports a default fallback color", () => { + expect(agentRoleColorDefault).toBe("text-muted-foreground"); + }); + + it("all 11 roles have distinct color classes", () => { + const colors = Object.values(agentRoleColors); + const unique = new Set(colors); + expect(unique.size).toBe(colors.length); + }); +}); diff --git a/ui/src/lib/agent-role-colors.ts b/ui/src/lib/agent-role-colors.ts new file mode 100644 index 00000000..af9b68b2 --- /dev/null +++ b/ui/src/lib/agent-role-colors.ts @@ -0,0 +1,17 @@ +import type { AgentRole } from "@paperclipai/shared"; + +export const agentRoleColors: Record = { + pm: "text-blue-600 dark:text-blue-400", + engineer: "text-violet-600 dark:text-violet-400", + ceo: "text-amber-600 dark:text-amber-400", + general: "text-slate-600 dark:text-slate-400", + designer: "text-pink-600 dark:text-pink-400", + qa: "text-orange-600 dark:text-orange-400", + researcher: "text-teal-600 dark:text-teal-400", + devops: "text-emerald-600 dark:text-emerald-400", + cto: "text-indigo-600 dark:text-indigo-400", + cmo: "text-rose-600 dark:text-rose-400", + cfo: "text-cyan-600 dark:text-cyan-400", +}; + +export const agentRoleColorDefault = "text-muted-foreground";