feat: workflow, UI and document support updates

This commit is contained in:
willchen96 2026-07-08 18:27:28 +08:00
parent a5fe6d6e04
commit 82dcaefc43
139 changed files with 12554 additions and 2233 deletions

View file

@ -7,6 +7,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1025.0",
"@aws-sdk/s3-request-presigner": "^3.1025.0",
"@fortune-sheet/core": "^1.0.4",
"@fortune-sheet/react": "^1.0.4",
"@opennextjs/cloudflare": "^1.19.9",
"@openrouter/sdk": "^0.3.11",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@ -15,6 +17,8 @@
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/auth-js": "^2.101.1",
"@supabase/supabase-js": "^2.81.1",
"@tiptap/core": "^3.22.3",
"@tiptap/extension-table": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
@ -28,6 +32,7 @@
"exceljs": "^4.4.0",
"katex": "^0.16.27",
"lucide-react": "^0.553.0",
"luckyexcel": "^1.0.1",
"mammoth": "^1.11.0",
"marked": "^17.0.1",
"next": "^16.2.6",
@ -329,6 +334,14 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@formulajs/formulajs": ["@formulajs/formulajs@2.9.3", "", { "dependencies": { "bessel": "^1.0.2", "jstat": "^1.9.2" }, "bin": { "implementation-stats": "bin/implementation-stats" } }, "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g=="],
"@fortune-sheet/core": ["@fortune-sheet/core@1.0.4", "", { "dependencies": { "@fortune-sheet/formula-parser": "^0.2.13", "dayjs": "^1.11.0", "immer": "^9.0.12", "lodash": "^4.17.21", "numeral": "^2.0.6", "uuid": "^8.3.2" } }, "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw=="],
"@fortune-sheet/formula-parser": ["@fortune-sheet/formula-parser@0.2.13", "", { "dependencies": { "@formulajs/formulajs": "^2.9.3", "tiny-emitter": "^2.1.0" } }, "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw=="],
"@fortune-sheet/react": ["@fortune-sheet/react@1.0.4", "", { "dependencies": { "@fortune-sheet/core": "^1.0.4", "@types/regenerator-runtime": "^0.13.6", "immer": "^9.0.12", "lodash": "^4.17.21", "regenerator-runtime": "^0.14.1" }, "peerDependencies": { "react": ">= 18.2", "react-dom": ">= 18.2" } }, "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@ -745,6 +758,8 @@
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="],
"@tiptap/extension-table": ["@tiptap/extension-table@3.27.3", "", { "peerDependencies": { "@tiptap/core": "3.27.3", "@tiptap/pm": "3.27.3" } }, "sha512-P7iQ0SkkDWZwnXzQDOVFLXktXlX67VQ3s2YxoJYKTHlc8rPZ4UCaSogFq0G2APyBlizlY3LbMNrDeZAVFOD8wQ=="],
"@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="],
@ -819,6 +834,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/regenerator-runtime": ["@types/regenerator-runtime@0.13.8", "", {}, "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg=="],
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
@ -973,6 +990,8 @@
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
"bessel": ["bessel@1.0.2", "", {}, "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw=="],
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
@ -1429,7 +1448,7 @@
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@ -1541,6 +1560,8 @@
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"jstat": ["jstat@1.9.6", "", {}, "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
@ -1593,6 +1614,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
@ -1631,6 +1654,8 @@
"lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="],
"luckyexcel": ["luckyexcel@1.0.1", "", { "dependencies": { "jszip": "^3.5.0" } }, "sha512-hvbJmCXNp/vST/huA6sieDn32Ib8bd80L9aIu5ZGxniJvZle7VlpHZrl6weLGaEnX99+t7cPAoYGqrqbfZp/AQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
@ -1795,6 +1820,8 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@ -1951,6 +1978,8 @@
"refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
"regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
@ -2131,6 +2160,8 @@
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
"tiny-emitter": ["tiny-emitter@2.1.0", "", {}, "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@ -2511,6 +2542,8 @@
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"recharts/immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"rehype-attr/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],

View file

@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "node ../scripts/build-workflows.js",
"build": "next build",
"start": "next start",
"lint": "eslint",

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { accountGlassSectionClassName } from "./accountStyles";
export function AccountSection({

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { Loader2 } from "lucide-react";
type AccountToggleSize = "sm" | "md";

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export const accountGlassInputClassName = cn(
"rounded-lg px-3 text-gray-900 placeholder:text-gray-400",

View file

@ -2,8 +2,8 @@
import { useEffect, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { Input } from "@/app/components/ui/input";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
MfaVerificationPopup,
needsMfaVerification,

View file

@ -9,7 +9,7 @@ import {
Plus,
RefreshCw,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Input } from "@/app/components/ui/input";
import { Modal } from "@/app/components/modals/Modal";
import { NewMcpModal } from "@/app/components/account/NewMcpModal";
import {

View file

@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { AccountSection } from "../AccountSection";
export default function FeaturesPage() {

View file

@ -3,7 +3,7 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { accountTabButtonClassName } from "./accountStyles";
interface TabDef {

View file

@ -9,8 +9,8 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useUserProfile } from "@/contexts/UserProfileContext";
} from "@/app/components/ui/dropdown-menu";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import {
MODELS,

View file

@ -3,10 +3,10 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { LogOut, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { Button } from "@/app/components/ui/button";
import { Input } from "@/app/components/ui/input";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import {
MfaVerificationPopup,

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Button } from "@/app/components/ui/button";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import {

View file

@ -8,9 +8,9 @@ import {
type KeyboardEvent,
} from "react";
import { Copy, Loader2 } from "lucide-react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { supabase } from "@/app/lib/supabase";
import { Button } from "@/app/components/ui/button";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { isMfaRequiredError } from "@/app/lib/mikeApi";
import { Modal } from "@/app/components/modals/Modal";
import {

View file

@ -3,7 +3,7 @@
import { useCallback, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { PanelLeft } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
import { SidebarContext } from "@/app/contexts/SidebarContext";
import { PageChromeContext } from "@/app/contexts/PageChromeContext";

View file

@ -43,9 +43,9 @@ import { PdfView } from "@/app/components/shared/views/PdfView";
import { SpreadsheetView } from "@/app/components/shared/views/SpreadsheetView";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { DocxView } from "@/app/components/shared/views/DocxView";
import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
@ -1116,7 +1116,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
)
}
rounded={false}
bordered={false}
/>
) : isSpreadsheetFilename(activeTab.filename) ? (
<SpreadsheetView
@ -1124,7 +1123,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
documentId={activeTab.documentId}
versionId={activeTab.versionId}
rounded={false}
bordered={false}
/>
) : (
<PdfView
@ -1132,7 +1130,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
doc={{ document_id: activeTab.documentId }}
quotes={activeQuotes ?? undefined}
rounded={false}
bordered={false}
/>
)
) : (

View file

@ -10,7 +10,7 @@ import {
useProjectWorkspace,
} from "@/app/components/projects/ProjectWorkspace";
import type { Chat } from "@/app/components/shared/types";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
interface Props {
params: Promise<{ id: string }>;

View file

@ -14,7 +14,7 @@ import {
useProjectWorkspace,
} from "@/app/components/projects/ProjectWorkspace";
import type { TabularReview } from "@/app/components/shared/types";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
interface Props {
params: Promise<{ id: string }>;

View file

@ -19,7 +19,7 @@ import { TableToolbar } from "@/app/components/shared/TableToolbar";
import { NewTRModal } from "@/app/components/tabular/NewTRModal";
import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import {
GLASS_DROPDOWN,

View file

@ -0,0 +1,333 @@
"use client";
import { Check, ChevronDown, Eye, EyeOff, Loader2 } from "lucide-react";
import { Input } from "@/app/components/ui/input";
import { Modal } from "@/app/components/modals/Modal";
import type { McpConnectorSummary } from "@/app/lib/mikeApi";
import {
accountGlassIconButtonClassName,
accountGlassInputClassName,
} from "@/app/(pages)/account/accountStyles";
export type NewMcpDraft = {
name: string;
serverUrl: string;
bearerToken: string;
customHeaders: string;
};
export type NewMcpStep = "form" | "working" | "auth" | "success";
interface NewMcpModalProps {
open: boolean;
draft: NewMcpDraft;
step: NewMcpStep;
result: McpConnectorSummary | null;
error: string | null;
authMessage: string | null;
showToken: boolean;
showAdvanced: boolean;
onDraftChange: (draft: NewMcpDraft) => void;
onShowTokenChange: (show: boolean) => void;
onShowAdvancedChange: (show: boolean) => void;
onClose: () => void;
onSubmit: () => Promise<void>;
onOpenConnector: (connectorId: string) => void;
}
export function NewMcpModal({
open,
draft,
step,
result,
error,
authMessage,
showToken,
showAdvanced,
onDraftChange,
onShowTokenChange,
onShowAdvancedChange,
onClose,
onSubmit,
onOpenConnector,
}: NewMcpModalProps) {
const canSubmit =
draft.name.trim().length > 0 &&
draft.serverUrl.trim().length > 0 &&
step !== "working" &&
step !== "auth";
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={[
"Connectors",
step === "success"
? "Connector added"
: step === "auth"
? "Authenticate connector"
: "New MCP connector",
]}
size="lg"
primaryAction={
step === "success" && result
? {
label: "View connector",
onClick: () => onOpenConnector(result.id),
}
: {
label:
step === "working"
? "Connecting..."
: step === "auth"
? "Authorizing..."
: "Connect",
icon:
step === "working" || step === "auth" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : undefined,
onClick: () => void onSubmit(),
disabled: !canSubmit,
}
}
cancelAction={
step === "working" || step === "auth"
? false
: {
label: step === "success" ? "Done" : "Cancel",
onClick: onClose,
}
}
footerStatus={
error ? (
<div className="rounded-xl border border-white/70 bg-white/75 px-3 py-2 text-sm text-red-600 shadow-[0_12px_32px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-xl">
{error}
</div>
) : null
}
>
{step === "success" && result ? (
<NewMcpSuccess connector={result} />
) : step === "auth" ? (
<NewMcpAuth
message={
authMessage ??
"Complete authorization in the popup to finish connecting this MCP server."
}
/>
) : (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4">
<p className="text-sm text-gray-500">
The assistant will have access to this MCP server and
its enabled tools.
</p>
<NewMcpForm
draft={draft}
showToken={showToken}
showAdvanced={showAdvanced}
disabled={step === "working"}
onDraftChange={onDraftChange}
onShowTokenChange={onShowTokenChange}
onShowAdvancedChange={onShowAdvancedChange}
/>
</div>
)}
</Modal>
);
}
function NewMcpForm({
draft,
showToken,
showAdvanced,
disabled,
onDraftChange,
onShowTokenChange,
onShowAdvancedChange,
}: {
draft: NewMcpDraft;
showToken: boolean;
showAdvanced: boolean;
disabled: boolean;
onDraftChange: (draft: NewMcpDraft) => void;
onShowTokenChange: (show: boolean) => void;
onShowAdvancedChange: (show: boolean) => void;
}) {
return (
<div className="grid gap-3 pt-1">
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center">
<span className="text-xs font-medium text-gray-500">
Label
</span>
<Input
value={draft.name}
onChange={(event) =>
onDraftChange({ ...draft, name: event.target.value })
}
placeholder="Connector label"
className={`h-8 text-sm ${accountGlassInputClassName}`}
disabled={disabled}
/>
</label>
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center">
<span className="text-xs font-medium text-gray-500">
URL endpoint
</span>
<Input
value={draft.serverUrl}
onChange={(event) =>
onDraftChange({
...draft,
serverUrl: event.target.value,
})
}
placeholder="https://mcp.example.com/mcp"
className={`h-8 text-sm ${accountGlassInputClassName}`}
disabled={disabled}
/>
</label>
<div className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start">
<span className="pt-2 text-xs font-medium text-gray-500">
Bearer token
</span>
<div className="min-w-0">
<div className="relative">
<Input
value={draft.bearerToken}
onChange={(event) =>
onDraftChange({
...draft,
bearerToken: event.target.value,
})
}
type={showToken ? "text" : "password"}
placeholder="Bearer token"
className={`h-8 pr-10 text-sm ${accountGlassInputClassName}`}
autoComplete="off"
spellCheck={false}
disabled={disabled}
/>
{draft.bearerToken && (
<button
type="button"
className={`absolute inset-y-1 right-1.5 flex items-center ${accountGlassIconButtonClassName}`}
onClick={() => onShowTokenChange(!showToken)}
aria-label={
showToken ? "Hide token" : "Show token"
}
disabled={disabled}
>
{showToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
</div>
<p className="mt-1 text-right text-xs text-gray-500">
Tokens are stored encrypted.
</p>
</div>
</div>
<div className="grid gap-2">
<button
type="button"
onClick={() => onShowAdvancedChange(!showAdvanced)}
className="inline-flex items-center gap-1 justify-self-start text-xs font-medium text-gray-500 transition-colors hover:text-gray-900"
disabled={disabled}
>
Advanced
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
showAdvanced ? "" : "-rotate-90"
}`}
/>
</button>
{showAdvanced && (
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start">
<span className="text-xs font-medium text-gray-500">
Custom headers
</span>
<div className="min-w-0">
<textarea
value={draft.customHeaders}
onChange={(event) =>
onDraftChange({
...draft,
customHeaders: event.target.value,
})
}
placeholder='{"X-API-Key":"secret"}'
className={`min-h-20 w-full resize-y rounded-lg px-3 py-2 text-sm outline-none ${accountGlassInputClassName}`}
autoComplete="off"
spellCheck={false}
disabled={disabled}
/>
<p className="mt-1 text-right text-xs text-gray-500">
Secrets are stored encrypted.
</p>
</div>
</label>
)}
</div>
</div>
);
}
function NewMcpSuccess({ connector }: { connector: McpConnectorSummary }) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col gap-4 pb-4">
<div className="flex items-start gap-3 rounded-xl border border-green-100/80 bg-green-50/80 px-3 py-3 text-green-800 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
<p className="min-w-0 truncate text-sm font-medium">
{connector.name} is connected.{" "}
<span className="font-normal text-green-700">
{connector.tools.length} tools discovered.
</span>
</p>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-100 bg-white/60">
<div className="max-h-full overflow-y-auto divide-y divide-gray-100">
{connector.tools.map((tool) => (
<div
key={tool.openaiToolName}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-800">
{tool.title ?? tool.openaiToolName}
</p>
{tool.description && (
<p className="truncate text-xs text-gray-500">
{tool.description}
</p>
)}
</div>
<span className="text-xs text-gray-400">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
</div>
))}
</div>
</div>
</div>
);
}
function NewMcpAuth({ message }: { message: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 pb-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/70 bg-white/75 text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
<div className="max-w-sm space-y-1">
<h3 className="text-sm font-medium text-gray-900">
Authentication required
</h3>
<p className="text-sm text-gray-500">{message}</p>
</div>
</div>
);
}

View file

@ -1,116 +1,40 @@
"use client";
import { useRef, useState } from "react";
import { PlusIcon, Upload, LayoutGridIcon, Loader2Icon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
import type { Document } from "../shared/types";
import { PlusIcon } from "lucide-react";
interface Props {
onSelectDoc: (doc: Document) => void;
onBrowseAll: () => void;
selectedDocIds?: string[];
hideLabel?: boolean;
}
export function AddDocButton({
onSelectDoc,
onBrowseAll,
selectedDocIds = [],
hideLabel = false,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) => uploadStandaloneDocument(f)),
);
uploaded.forEach((doc) => onSelectDoc(doc));
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
multiple
className="hidden"
onChange={handleUpload}
/>
<DropdownMenu onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<button
className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${
selectedDocIds.length > 0
? "text-black hover:bg-gray-100"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
} ${isOpen ? "bg-gray-100" : ""}`}
title="Add documents"
aria-label="Add documents"
>
{selectedDocIds.length > 0 ? (
<span className="font-medium tabular-nums">{selectedDocIds.length}</span>
) : (
<PlusIcon
className={`h-4 w-4 shrink-0 transition-transform duration-300 ${isOpen ? "rotate-[135deg]" : ""}`}
/>
)}
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
{selectedDocIds.length === 1
? "Document"
: "Documents"}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 z-50"
side="bottom"
align="start"
>
<DropdownMenuItem
className="cursor-pointer"
disabled={uploading}
onSelect={(e) => {
e.preventDefault();
fileInputRef.current?.click();
}}
>
{uploading ? (
<Loader2Icon className="h-4 w-4 mr-2 animate-spin text-gray-400" />
) : (
<Upload className="h-4 w-4 mr-2 text-gray-500" />
)}
<span className="text-sm">
{uploading ? "Uploading…" : "Upload files"}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={onBrowseAll}
>
<LayoutGridIcon className="h-4 w-4 mr-2 text-gray-500" />
<span className="text-sm">Browse all</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
<button
type="button"
onClick={onBrowseAll}
className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${
selectedDocIds.length > 0
? "text-black hover:bg-gray-100"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
}`}
title="Add documents"
aria-label="Add documents"
>
{selectedDocIds.length > 0 ? (
<span className="font-medium tabular-nums">
{selectedDocIds.length}
</span>
) : (
<PlusIcon className="h-4 w-4 shrink-0" />
)}
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
{selectedDocIds.length === 1 ? "Document" : "Documents"}
</span>
</button>
);
}

View file

@ -9,7 +9,7 @@ import {
Upload,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import type { AssistantEvent, Document } from "../shared/types";
import { FileTypeIcon } from "../shared/FileTypeIcon";
import {

View file

@ -17,7 +17,7 @@ import {
CaseLawPanel,
type CaseTab,
} from "./CaseLawPanel";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
// ---------------------------------------------------------------------------
// Tab data

View file

@ -13,7 +13,7 @@ import {
Download,
ExternalLink,
} from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import type { CaseCitationQuote } from "../shared/types";
import {
clearDocxQuoteHighlights,
@ -27,7 +27,7 @@ import {
getCourtlistenerOpinions,
type CaseLawOpinion,
} from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export type CaseTab = {
kind: "case";

View file

@ -23,14 +23,14 @@ import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
import { ModelToggle } from "./ModelToggle";
import { useSelectedModel } from "@/app/hooks/useSelectedModel";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
getModelProvider,
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { Document, Message } from "../shared/types";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export interface ChatInputHandle {
addDoc: (doc: Document) => void;
@ -96,13 +96,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
return () => observer.disconnect();
}, []);
const handleAddDocFromProject = useCallback((doc: Document) => {
setAttachedDocs((prev) => {
if (prev.some((d) => d.id === doc.id)) return prev;
return [...prev, doc];
});
}, []);
const handleAddDocsFromSelector = useCallback(
(selectedDocs: Document[]) => {
setAttachedDocs((prev) => {
@ -244,7 +237,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
<div className="flex items-center gap-1">
{!hideAddDocButton && (
<AddDocButton
onSelectDoc={handleAddDocFromProject}
onBrowseAll={() => setDocSelectorOpen(true)}
selectedDocIds={attachedDocs.map(
(d) => d.id,
@ -338,7 +330,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
open={workflowModalOpen}
onClose={() => setWorkflowModalOpen(false)}
onSelect={(wf) => {
setSelectedWorkflow({ id: wf.id, title: wf.title });
setSelectedWorkflow({
id: wf.id,
title: wf.metadata.title,
});
setWorkflowModalOpen(false);
}}
projectName={projectName}

View file

@ -0,0 +1,303 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { Minus, RectangleHorizontal, Rows3 } from "lucide-react";
import { CiteButton } from "@/app/components/ui/cite-button";
export type CitationQuoteHeaderItem = {
id: string;
quote: string;
eyebrow?: string | null;
inlineDetail?: string | null;
detail?: string | null;
citationText?: string | null;
};
const QUOTE_GLASS_SURFACE =
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
const QUOTE_CARD_SURFACE = "rounded-2xl bg-gray-100";
interface Props {
quotes: CitationQuoteHeaderItem[];
error?: string | null;
isLoading?: boolean;
activeQuoteId?: string | null;
currentIndex?: number;
citationRef?: number;
citationText?: string;
onSelect?: (quote: CitationQuoteHeaderItem, index: number) => void;
onIndexChange?: (index: number) => void;
}
export function CitationQuotesHeader({
quotes,
error = null,
isLoading = false,
activeQuoteId = null,
currentIndex = 0,
citationRef,
citationText,
onSelect,
onIndexChange,
}: Props) {
const [isExpanded, setIsExpanded] = useState(true);
const [viewMode, setViewMode] = useState<"single" | "list">("single");
const hasMultipleQuotes = quotes.length > 1;
const currentQuote = quotes[currentIndex];
useEffect(() => {
if (!hasMultipleQuotes && viewMode === "list") {
setViewMode("single");
}
}, [hasMultipleQuotes, viewMode]);
return (
<div className="px-3">
<div className={QUOTE_GLASS_SURFACE}>
<div className="flex h-10 items-center justify-between px-2">
<div className="flex items-center gap-2">
<p className="flex items-center gap-1.5 text-xs font-medium text-gray-700">
<span>Citation</span>
{typeof citationRef === "number" && (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-gray-200 px-1 text-[9px] font-medium text-gray-600">
{citationRef}
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
{hasMultipleQuotes && (
<div className="flex items-center gap-1">
<span className="mr-0.5 text-xs font-medium text-gray-500">
Quotes
</span>
{quotes.map((quote, index) => (
<button
key={quote.id}
type="button"
onClick={() => onIndexChange?.(index)}
className={`flex h-4 w-4 items-center justify-center rounded-full text-[9px] transition-colors ${
currentIndex === index
? "bg-white font-medium text-gray-800 shadow-[0_1px_3px_rgba(0,0,0,0.22)]"
: "bg-gray-200 text-gray-500 hover:bg-gray-300 hover:text-gray-700"
}`}
>
{index + 1}
</button>
))}
</div>
)}
{currentQuote && (
<CiteButton
quoteText={currentQuote.quote}
citationText={
currentQuote.citationText ??
citationText ??
""
}
className="rounded-sm bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
showText
/>
)}
<div
className={`relative flex h-6 items-center justify-start gap-1 rounded-sm bg-gray-200 p-1 ${
hasMultipleQuotes ? "w-16" : "w-11"
}`}
>
<div
className={`absolute top-1 h-4 w-4 rounded bg-white shadow-sm transition-all ${
!isExpanded
? "left-1"
: hasMultipleQuotes &&
viewMode === "list"
? "left-11"
: "left-6"
}`}
/>
<button
type="button"
onClick={() => setIsExpanded(false)}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
!isExpanded
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
}`}
title="Minimize"
>
<Minus className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => {
setIsExpanded(true);
setViewMode("single");
}}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
isExpanded && viewMode === "single"
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
}`}
title="Single quote"
>
<RectangleHorizontal className="h-3 w-3" />
</button>
{hasMultipleQuotes && (
<button
type="button"
onClick={() => {
setIsExpanded(true);
setViewMode("list");
}}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
isExpanded && viewMode === "list"
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
}`}
title="Quote list"
>
<Rows3 className="h-3 w-3" />
</button>
)}
</div>
</div>
</div>
{isExpanded && (
<div className="px-2 pb-2">
{isLoading ? (
<RelevantQuoteSkeleton />
) : error ? (
<RelevantQuoteMessage tone="error">
{error}
</RelevantQuoteMessage>
) : quotes.length > 0 ? (
viewMode === "list" ? (
<div className="space-y-2">
{quotes.map((quote, index) => (
<QuoteItem
key={quote.id}
quote={quote}
isActive={
activeQuoteId === quote.id
}
onClick={() =>
onSelect?.(quote, index)
}
/>
))}
</div>
) : currentQuote ? (
<div className="flex flex-col gap-2">
<QuoteItem
quote={currentQuote}
isActive={
activeQuoteId === currentQuote.id
}
onClick={() =>
onSelect?.(
currentQuote,
currentIndex,
)
}
/>
</div>
) : null
) : (
<RelevantQuoteMessage>
No relevant quotes.
</RelevantQuoteMessage>
)}
</div>
)}
</div>
</div>
);
}
function RelevantQuoteSkeleton() {
return (
<div className={`animate-pulse px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}>
<div className="h-3 w-28 rounded bg-gray-200" />
<div className="mt-2.5 h-3 w-full rounded bg-gray-200" />
<div className="mt-2 h-3 w-11/12 rounded bg-gray-200" />
<div className="mt-2 h-3 w-2/3 rounded bg-gray-200" />
</div>
);
}
function RelevantQuoteMessage({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: "neutral" | "error";
}) {
return (
<div className={`px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}>
<p
className={`font-serif text-sm leading-6 ${
tone === "error" ? "text-red-700" : "text-gray-600"
}`}
>
{children}
</p>
</div>
);
}
function QuoteItem({
quote,
isActive,
onClick,
}: {
quote: CitationQuoteHeaderItem;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full rounded-xl px-3 py-2.5 text-left transition-colors ${
isActive ? "bg-blue-100/70" : "bg-gray-100 hover:bg-gray-200/70"
}`}
>
<div className="flex flex-col gap-1.5">
{quote.eyebrow && (
<p
className={`font-serif text-xs ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{quote.eyebrow}
</p>
)}
<p
className={`font-serif text-sm leading-6 ${
isActive ? "text-blue-950" : "text-gray-700"
}`}
>
&ldquo;{quote.quote.replace(/"/g, "'")}&rdquo;
{quote.inlineDetail && (
<span
className={`text-sm ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{" "}
({quote.inlineDetail})
</span>
)}
</p>
{quote.detail && (
<p
className={`font-serif text-xs ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{quote.detail}
</p>
)}
</div>
</button>
);
}

View file

@ -0,0 +1,402 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { supabase } from "@/app/lib/supabase";
import { PdfView } from "../shared/views/PdfView";
import { DocxView } from "../shared/views/DocxView";
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
import {
CitationQuotesHeader,
type CitationQuoteHeaderItem,
} from "./CitationQuotesHeader";
import { TrackedChangeHeader } from "./TrackedChangeHeader";
import {
cleanCitationQuoteText,
expandCitationToEntries,
formatCitationPage,
formatCitationQuotePage,
getDocumentCitationQuotes,
isSpreadsheetFilename,
} from "../shared/types";
import type {
CitationQuote,
Citation,
DocumentCitation,
EditAnnotation,
} from "../shared/types";
function isDocxFilename(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase();
return ext === "docx" || ext === "doc";
}
/**
* Discriminated-union describing what the panel is showing above the viewer.
* - "document": title row + viewer.
* - "citation": title row + relevant quote + viewer.
* - "edit": title row + tracked change + viewer.
*/
export type DocPanelMode =
| { kind: "document" }
| { kind: "citation"; citation: Citation }
| {
kind: "edit";
edit: EditAnnotation;
changeNumber?: number;
/**
* True while an accept/reject request for this exact edit is in
* flight. Scoped per-edit (not per-document) so sibling edits on
* the same doc stay clickable.
*/
isEditReloading?: boolean;
onResolveStart?: (args: {
editId: string;
documentId: string;
verb: "accept" | "reject";
}) => void;
onResolved?: (args: {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
}) => void;
onError?: (args: {
editId: string;
documentId: string;
versionId: string | null;
message: string;
}) => void;
};
interface Props {
documentId: string;
filename: string;
versionId: string | null;
versionNumber: number | null;
mode: DocPanelMode;
/** Spinner on the Download button while an accept/reject is in flight. */
isReloading?: boolean;
warning?: string | null;
onWarningDismiss?: () => void;
initialScrollTop?: number | null;
onScrollChange?: (scrollTop: number) => void;
}
/**
* Unified side-panel body for the assistant. Renders a single document
* with optionally a citation quote OR a tracked change highlighted above
* the viewer. No selector UI caller picks the one thing to show; if the
* user wants a different citation/edit, the panel gets a new tab.
*/
export function DocPanel({
documentId,
filename,
versionId,
versionNumber,
mode,
isReloading = false,
warning,
onWarningDismiss,
initialScrollTop,
onScrollChange,
}: Props) {
// Pick the viewer from the filename only, not from mode. Switching
// headers (citation ↔ edit ↔ document) for the same document must
// not unmount and remount the body — otherwise the user sees a full
// re-fetch every time they toggle. Tracked-change rendering still
// only lives in DocxView, which is fine because edits are DOCX-only.
const useDocxView = isDocxFilename(filename);
const useSheetView = isSpreadsheetFilename(filename);
const citationQuoteId =
mode.kind === "citation" ? `document:${mode.citation.ref}:0` : null;
const [activeCitationQuoteId, setActiveCitationQuoteId] = useState<
string | null
>(citationQuoteId);
const [quoteFocusKey, setQuoteFocusKey] = useState(0);
const [editFocusKey, setEditFocusKey] = useState(0);
const quotes: CitationQuote[] | undefined = useMemo(() => {
if (mode.kind !== "citation") return undefined;
if (!activeCitationQuoteId) return [];
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
if (!Number.isFinite(selectedIndex)) return [];
const selectedQuote =
getDocumentCitationQuotes(mode.citation)[selectedIndex];
if (!selectedQuote) return [];
const documentCitation = mode.citation as DocumentCitation;
return expandCitationToEntries({
...documentCitation,
page: selectedQuote.page,
quote: selectedQuote.quote,
quotes: [selectedQuote],
});
}, [activeCitationQuoteId, citationQuoteId, mode]);
// Cell locator(s) for the selected quote, used to highlight the cited cell
// when the document is a spreadsheet.
const highlightCells = useMemo(() => {
if (mode.kind !== "citation") return undefined;
if (!activeCitationQuoteId) return [];
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
if (!Number.isFinite(selectedIndex)) return [];
const selectedQuote =
getDocumentCitationQuotes(mode.citation)[selectedIndex];
if (!selectedQuote || (!selectedQuote.cell && !selectedQuote.sheet))
return [];
return [{ sheet: selectedQuote.sheet, cell: selectedQuote.cell }];
}, [activeCitationQuoteId, mode]);
useEffect(() => {
setActiveCitationQuoteId(citationQuoteId);
}, [citationQuoteId]);
const handleCitationQuoteSelect = useCallback(
(quoteId: string) => {
const shouldSelect = activeCitationQuoteId !== quoteId;
setActiveCitationQuoteId(shouldSelect ? quoteId : null);
if (shouldSelect) setQuoteFocusKey((current) => current + 1);
},
[activeCitationQuoteId],
);
const highlightEdit = useMemo(() => {
if (mode.kind !== "edit") return null;
return {
key: `${mode.edit.edit_id}:${editFocusKey}`,
inserted_text: mode.edit.inserted_text,
deleted_text: mode.edit.deleted_text,
ins_w_id: mode.edit.ins_w_id ?? null,
del_w_id: mode.edit.del_w_id ?? null,
};
}, [editFocusKey, mode]);
return (
<div className="flex h-full flex-col">
<DocumentTitleRow
documentId={documentId}
filename={filename}
versionId={versionId}
versionNumber={versionNumber}
isReloading={isReloading}
/>
{mode.kind === "citation" && (
<RelevantQuoteSection
citation={mode.citation}
filename={filename}
activeQuoteId={activeCitationQuoteId}
onQuoteSelect={handleCitationQuoteSelect}
/>
)}
{mode.kind === "edit" && (
<TrackedChangeHeader
edit={mode.edit}
changeNumber={mode.changeNumber}
isEditReloading={mode.isEditReloading}
onResolveStart={mode.onResolveStart}
onResolved={mode.onResolved}
onError={mode.onError}
onHighlight={() => setEditFocusKey((current) => current + 1)}
/>
)}
<div className="flex flex-1 min-h-0 flex-col px-3 py-3">
{useDocxView ? (
<DocxView
documentId={documentId}
versionId={versionId ?? undefined}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
highlightEdit={highlightEdit}
warning={warning ?? null}
onWarningDismiss={onWarningDismiss}
initialScrollTop={initialScrollTop ?? null}
onScrollChange={onScrollChange}
/>
) : useSheetView ? (
<SpreadsheetView
documentId={documentId}
versionId={versionId}
highlightCells={highlightCells}
/>
) : (
<PdfView
doc={{
document_id: documentId,
version_id: versionId,
}}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
/>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Header variants
// ---------------------------------------------------------------------------
function DocumentTitleRow({
documentId,
filename,
versionId,
versionNumber,
isReloading,
}: {
documentId: string;
filename: string;
versionId: string | null;
versionNumber: number | null;
isReloading: boolean;
}) {
return (
<div className="flex items-start gap-3 px-3 pt-4 pb-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2
className="min-w-0 break-words font-serif text-xl text-gray-900"
title={filename}
>
{filename}
</h2>
{versionNumber && versionNumber > 0 && (
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
V{versionNumber}
</span>
)}
</div>
</div>
<div className="shrink-0">
<DownloadButton
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
/>
</div>
</div>
);
}
function RelevantQuoteSection({
citation,
filename,
activeQuoteId,
onQuoteSelect,
}: {
citation: Citation;
filename: string;
activeQuoteId: string | null;
onQuoteSelect: (quoteId: string) => void;
}) {
const citationQuotes = getDocumentCitationQuotes(citation);
const pagesLabel = formatCitationPage(citation);
const citationText = [filename, pagesLabel].filter(Boolean).join(", ");
const relevantQuotes: CitationQuoteHeaderItem[] = citationQuotes.map(
(quote, index) => {
const pageLabel = formatCitationQuotePage(
citation,
quote.page,
quote,
);
return {
id: `document:${citation.ref}:${index}`,
quote: cleanCitationQuoteText(citation, quote.quote),
inlineDetail: pageLabel || null,
citationText: [filename, pageLabel].filter(Boolean).join(", "),
};
},
);
const currentIndex = Math.max(
0,
relevantQuotes.findIndex((quote) => quote.id === activeQuoteId),
);
return (
<CitationQuotesHeader
quotes={relevantQuotes}
activeQuoteId={activeQuoteId}
currentIndex={currentIndex}
citationRef={citation.ref}
citationText={citationText}
onSelect={(quote) => onQuoteSelect(quote.id)}
onIndexChange={(index) => {
const quote = relevantQuotes[index];
if (quote) onQuoteSelect(quote.id);
}}
/>
);
}
// ---------------------------------------------------------------------------
// Download button
// ---------------------------------------------------------------------------
function DownloadButton({
documentId,
versionId,
filename,
isReloading,
}: {
documentId: string;
versionId: string | null;
filename: string;
isReloading?: boolean;
}) {
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy || isReloading) return;
setBusy(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
const qs = versionId
? `?version_id=${encodeURIComponent(versionId)}`
: "";
const resp = await fetch(
`${apiBase}/single-documents/${documentId}/docx${qs}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} finally {
setBusy(false);
}
};
const spinning = busy || isReloading;
return (
<button
onClick={handleClick}
disabled={spinning}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{spinning ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
Download
</button>
);
}

View file

@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { supabase } from "@/lib/supabase";
import { PillButton } from "@/components/ui/pill-button";
import { supabase } from "@/app/lib/supabase";
import { PillButton } from "@/app/components/ui/pill-button";
import type { EditAnnotation } from "../shared/types";
function normalizeText(s: string) {

View file

@ -1,9 +1,9 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { ChatInput } from "./ChatInput";
import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal";
import type { Message } from "../shared/types";

View file

@ -9,7 +9,7 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/components/ui/dropdown-menu";
import { isModelAvailable } from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";

View file

@ -0,0 +1,80 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
export function PreResponseWrapper({
children,
stepCount,
shouldMinimize,
isStreaming,
compact = false,
forceOpen = false,
}: {
children: React.ReactNode;
stepCount: number;
shouldMinimize: boolean;
isStreaming: boolean;
/** Tighter typography + child gap for narrow side panels (e.g. TR chat). */
compact?: boolean;
forceOpen?: boolean;
}) {
const [userToggled, setUserToggled] = useState(false);
const [isOpen, setIsOpen] = useState(!shouldMinimize);
// Once content has streamed in (shouldMinimize=true even once), stay
// minimized even if a later render briefly evaluates shouldMinimize=false.
// Without this latch, the wrapper visibly pops open when isStreaming
// flips off at the end of the response.
const hasMinimizedRef = useRef(shouldMinimize);
useEffect(() => {
if (forceOpen) {
setIsOpen(true);
return;
}
if (shouldMinimize) hasMinimizedRef.current = true;
if (userToggled) return;
setIsOpen(!shouldMinimize && !hasMinimizedRef.current);
}, [forceOpen, shouldMinimize, userToggled]);
const stepWord = `step${stepCount === 1 ? "" : "s"}`;
const label = isStreaming
? "Working"
: `Completed in ${stepCount} ${stepWord}`;
const buttonTextClass = compact ? "text-xs" : "text-sm";
const childrenGapClass = compact ? "gap-2.5" : "gap-4";
return (
<div className="rounded-xl border border-white/70 bg-white/55 px-3 py-2 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl">
<button
type="button"
onClick={() => {
setUserToggled(true);
setIsOpen((v) => !v);
}}
className={`w-full flex items-center justify-between font-serif text-gray-500 hover:text-gray-700 transition-colors ${buttonTextClass}`}
>
<span className="flex items-baseline min-w-0">
<span className="truncate">{label}</span>
{isStreaming && (
<span className="inline-flex ml-1 shrink-0 items-baseline">
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0s]" />
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0.2s]" />
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 animate-[bounce_1.4s_infinite_0.4s]" />
</span>
)}
</span>
<ChevronDown
size={12}
className={`relative top-px shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
</button>
{isOpen && (
<div className={`mt-3 flex flex-col ${childrenGapClass}`}>
{children}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,229 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/app/lib/supabase";
import { PillButton } from "@/app/components/ui/pill-button";
import { applyOptimisticResolution } from "./EditCard";
import type { EditAnnotation } from "../shared/types";
const PANEL_GLASS_SURFACE =
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
const PANEL_CARD_SURFACE = "rounded-lg bg-gray-100";
type ResolveArgs = {
editId: string;
documentId: string;
verb: "accept" | "reject";
};
type ResolvedArgs = {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
};
type ErrorArgs = {
editId: string;
documentId: string;
versionId: string | null;
message: string;
};
export function TrackedChangeHeader({
edit,
changeNumber,
isEditReloading,
onResolveStart,
onResolved,
onError,
onHighlight,
}: {
edit: EditAnnotation;
changeNumber?: number;
isEditReloading?: boolean;
onResolveStart?: (args: ResolveArgs) => void;
onResolved?: (args: ResolvedArgs) => void;
onError?: (args: ErrorArgs) => void;
onHighlight?: () => void;
}) {
return (
<div className="px-3 pb-3">
<div className={`${PANEL_GLASS_SURFACE} px-2 py-2`}>
<div className="mb-1 flex items-center gap-2">
<p className="text-xs font-medium text-gray-700">
Tracked Change
{changeNumber !== undefined ? ` ${changeNumber}` : ""}
</p>
<div className="ml-auto flex shrink-0 items-center gap-2">
<EditResolveButtons
edit={edit}
isReloading={isEditReloading}
onResolveStart={onResolveStart}
onResolved={onResolved}
onError={onError}
/>
</div>
</div>
{edit.reason && (
<p className="mb-3 text-xs text-gray-500">{edit.reason}</p>
)}
<div
className={`w-full px-3 py-2.5 text-left transition-colors ${PANEL_CARD_SURFACE} ${
onHighlight ? "cursor-pointer hover:bg-gray-200/70" : ""
}`}
role={onHighlight ? "button" : undefined}
tabIndex={onHighlight ? 0 : undefined}
onClick={onHighlight}
onKeyDown={(event) => {
if (!onHighlight) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onHighlight();
}}
>
<div className="font-serif text-sm leading-relaxed">
{edit.inserted_text && (
<span className="text-green-700">
{edit.inserted_text}
</span>
)}
{edit.deleted_text && (
<span className="text-red-600 line-through">
{edit.deleted_text}
</span>
)}
</div>
</div>
</div>
</div>
);
}
function EditResolveButtons({
edit,
isReloading,
onResolveStart,
onResolved,
onError,
}: {
edit: EditAnnotation;
isReloading?: boolean;
onResolveStart?: (args: ResolveArgs) => void;
onResolved?: (args: ResolvedArgs) => void;
onError?: (args: ErrorArgs) => void;
}) {
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<"pending" | "accepted" | "rejected">(
edit.status,
);
useEffect(() => {
if (busy) return;
setStatus(edit.status);
}, [edit.status, edit.edit_id, busy]);
const resolved = status !== "pending";
const handle = useCallback(
async (verb: "accept" | "reject") => {
if (busy || resolved) return;
setBusy(true);
onResolveStart?.({
editId: edit.edit_id,
documentId: edit.document_id,
verb,
});
let revert: (() => void) | null = null;
try {
revert = applyOptimisticResolution(edit, verb);
} catch (e) {
console.error(
"[TrackedChangeHeader] optimistic update threw",
e,
);
}
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ??
"http://localhost:3001";
const resp = await fetch(
`${apiBase}/single-documents/${edit.document_id}/edits/${edit.edit_id}/${verb}`,
{
method: "POST",
headers: token
? { Authorization: `Bearer ${token}` }
: undefined,
},
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = (await resp.json()) as {
ok: boolean;
status?: "accepted" | "rejected";
version_id: string | null;
download_url: string | null;
};
const nextStatus =
data.status ??
(verb === "accept" ? "accepted" : "rejected");
setStatus(nextStatus);
onResolved?.({
editId: edit.edit_id,
documentId: edit.document_id,
status: nextStatus,
versionId: data.version_id,
downloadUrl: data.download_url,
});
} catch (e) {
console.error("[TrackedChangeHeader] resolve failed", e);
try {
revert?.();
} catch (revertErr) {
console.error(
"[TrackedChangeHeader] revert threw",
revertErr,
);
}
onError?.({
editId: edit.edit_id,
documentId: edit.document_id,
versionId: edit.version_id ?? null,
message:
verb === "accept"
? "Couldn't save accept — please retry."
: "Couldn't save reject — please retry.",
});
} finally {
setBusy(false);
}
},
[busy, resolved, edit, onResolveStart, onResolved, onError],
);
const inFlight = busy || !!isReloading;
return (
<div className="flex items-center gap-2">
<PillButton
tone="black"
size="sm"
onClick={() => handle("accept")}
disabled={inFlight || resolved}
>
{status === "accepted" ? "Accepted" : "Accept"}
</PillButton>
<PillButton
tone="white"
size="sm"
onClick={() => handle("reject")}
disabled={inFlight || resolved}
>
{status === "rejected" ? "Rejected" : "Reject"}
</PillButton>
</div>
);
}

View file

@ -0,0 +1,206 @@
import { Loader2, Scale } from "lucide-react";
import { FileTypeIcon } from "../../shared/FileTypeIcon";
import { displayCitationQuote, formatCitationPage } from "../../shared/types";
import type { Citation } from "../../shared/types";
import { RESPONSE_GLASS_ANNOTATION, RESPONSE_GLASS_SURFACE } from "./messageStyles";
type CitationSourceRow = {
key: string;
label: string;
source: Citation;
entries: { annotation: Citation; index: number }[];
};
function citationSourceKey(annotation: Citation): string {
if (annotation.kind === "case") {
return `case:${annotation.cluster_id}`;
}
return `document:${annotation.document_id}`;
}
function citationSourceLabel(annotation: Citation): string {
if (annotation.kind === "case") {
const caseName = annotation.case_name?.trim();
const citation = annotation.citation?.trim();
if (caseName && citation) return `${caseName}, ${citation}`;
return caseName || citation || `Case ${annotation.cluster_id}`;
}
return annotation.filename;
}
export function citationTooltip(annotation: Citation): string {
const locator = formatCitationPage(annotation);
const quote = displayCitationQuote(annotation);
return locator ? `${locator}: "${quote}"` : `"${quote}"`;
}
function CitationSourceIcon({
annotation,
}: {
annotation: Citation;
}) {
if (annotation.kind === "case") {
return <Scale className="h-3.5 w-3.5 text-slate-600" />;
}
return (
<FileTypeIcon fileType={annotation.filename} className="h-3.5 w-3.5" />
);
}
function buildCitationSourceRows(
citations: Citation[],
): CitationSourceRow[] {
const rows = new Map<string, CitationSourceRow>();
citations.forEach((annotation, index) => {
const key = citationSourceKey(annotation);
const existing = rows.get(key);
if (existing) {
existing.entries.push({ annotation, index });
return;
}
rows.set(key, {
key,
label: citationSourceLabel(annotation),
source: annotation,
entries: [{ annotation, index }],
});
});
return Array.from(rows.values());
}
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function ensureTerminalPeriod(value: string): string {
return /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`;
}
export function buildCitationAppendix(citations: Citation[]) {
if (citations.length === 0) return { html: "", text: "" };
let previousSourceKey: string | null = null;
const entries = citations.map((annotation) => {
const sourceKey = citationSourceKey(annotation);
const label =
sourceKey === previousSourceKey
? "Id."
: citationSourceLabel(annotation);
previousSourceKey = sourceKey;
return {
number: annotation.ref,
label,
quote: displayCitationQuote(annotation).trim(),
};
});
const textLines = [
"",
"Citations",
...entries.map((entry) => {
const quote = entry.quote ? ` "${entry.quote}"` : "";
return `${entry.number} ${ensureTerminalPeriod(entry.label)}${quote}`;
}),
];
const html = [
`<section class="copied-citations">`,
`<h3>Citations</h3>`,
...entries.map((entry) => {
const label = escapeHtmlText(ensureTerminalPeriod(entry.label));
const quote = entry.quote
? ` &quot;${escapeHtmlText(entry.quote)}&quot;`
: "";
return `<p><sup>${entry.number}</sup> ${label}${quote}</p>`;
}),
`</section>`,
].join("");
return { html, text: textLines.join("\n") };
}
export function CitationsBlock({
citations,
onCitationClick,
onOpenSource,
canOpenSource,
showWhenEmpty = false,
isLoading = false,
}: {
citations: Citation[];
onCitationClick?: (citation: Citation) => void;
onOpenSource?: (citation: Citation) => void;
canOpenSource?: (citation: Citation) => boolean;
showWhenEmpty?: boolean;
isLoading?: boolean;
}) {
const rows = buildCitationSourceRows(citations);
if (rows.length === 0 && !showWhenEmpty) return null;
return (
<div className="mt-2 mb-3">
<div className={`overflow-hidden ${RESPONSE_GLASS_SURFACE}`}>
<div className="flex items-center justify-between gap-3 bg-white/25 px-3 py-2">
<h3 className="text-base font-serif text-gray-900">
Citations
</h3>
{isLoading && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />
)}
</div>
<div>
{rows.map((row) => {
const sourceIsClickable =
!!onOpenSource &&
(canOpenSource?.(row.source) ?? true);
return (
<div
key={row.key}
className="flex items-center gap-3 px-3 py-3"
>
<button
type="button"
onClick={() => onOpenSource?.(row.source)}
disabled={!sourceIsClickable}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg text-left text-sm font-serif text-gray-700 transition-colors enabled:hover:text-gray-950 disabled:cursor-default"
>
<CitationSourceIcon
annotation={row.source}
/>
<span className="truncate">
{row.label}
</span>
</button>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
{row.entries.map(
({ annotation, index }) => (
<button
key={`${row.key}:${index}`}
type="button"
onClick={() =>
onCitationClick?.(
annotation,
)
}
className={
RESPONSE_GLASS_ANNOTATION
}
title={citationTooltip(
annotation,
)}
>
{annotation.ref}
</button>
),
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,289 @@
import { useState, type ReactNode } from "react";
import { ChevronDown, Loader2 } from "lucide-react";
import { PillButton } from "@/app/components/ui/pill-button";
import { supabase } from "@/app/lib/supabase";
import type { EditAnnotation } from "../../shared/types";
import { applyOptimisticResolution } from "../EditCard";
/**
* Card rendered above the per-edit EditCards when a message produced
* multiple tracked-change proposals. Lets the user resolve every pending
* edit in one click by firing the per-edit accept/reject endpoint for each
* pending annotation and forwarding each response to `onResolved` so the
* parent can bump the viewer version, persist override URLs, etc.
*
* This intentionally doesn't apply the optimistic DOM mutation that
* EditCard does bulk operations touch many edits at once and the real
* re-render from the latest version will reconcile within a second or so.
*/
function BulkEditActions({
pending,
onViewClick,
onResolveStart,
onResolved,
onError,
}: {
pending: {
annotation: EditAnnotation;
filename: string;
}[];
onViewClick?: (ann: EditAnnotation, filename: string) => void;
onResolveStart?: (args: {
editId: string;
documentId: string;
verb: "accept" | "reject";
}) => void;
onResolved?: (args: {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
}) => void;
onError?: (args: {
editId: string;
documentId: string;
versionId: string | null;
message: string;
}) => void;
}) {
const [busy, setBusy] = useState<"accept" | "reject" | null>(null);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
if (pending.length === 0) return null;
const handleAll = async (verb: "accept" | "reject") => {
if (busy) return;
setBusy(verb);
setProgress({ done: 0, total: pending.length });
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
// Sequential so the per-document version counter advances in a
// predictable order and the viewer doesn't race between bumps.
let done = 0;
for (const { annotation } of pending) {
onResolveStart?.({
editId: annotation.edit_id,
documentId: annotation.document_id,
verb,
});
// Optimistically mutate the DOM so the viewer reflects the
// resolution immediately. Revert if the backend call fails.
let revert: (() => void) | null = null;
try {
revert = applyOptimisticResolution(annotation, verb);
} catch (e) {
console.error(
"[BulkEditActions] optimistic update threw",
e,
);
}
try {
const resp = await fetch(
`${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`,
{
method: "POST",
headers: token
? { Authorization: `Bearer ${token}` }
: undefined,
},
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = (await resp.json()) as {
ok: boolean;
status?: "accepted" | "rejected";
version_id: string | null;
download_url: string | null;
};
const nextStatus =
data.status ??
(verb === "accept" ? "accepted" : "rejected");
onResolved?.({
editId: annotation.edit_id,
documentId: annotation.document_id,
status: nextStatus,
versionId: data.version_id,
downloadUrl: data.download_url,
});
} catch (e) {
console.error("[BulkEditActions] resolve failed", e);
try {
revert?.();
} catch (revertErr) {
console.error(
"[BulkEditActions] revert threw",
revertErr,
);
}
onError?.({
editId: annotation.edit_id,
documentId: annotation.document_id,
versionId: annotation.version_id ?? null,
message:
verb === "accept"
? "Couldn't save one or more accepts."
: "Couldn't save one or more rejects.",
});
}
done++;
setProgress({ done, total: pending.length });
}
} finally {
setBusy(null);
setProgress(null);
}
};
// Optional: show a tiny "View first" action so bulk doesn't lose the
// in-viewer scroll-to behaviour entirely.
const first = pending[0];
return (
<div className="flex items-center gap-2">
<PillButton
tone="black"
size="sm"
onClick={() => handleAll("accept")}
disabled={!!busy}
>
{busy === "accept" && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Accept all
</PillButton>
<PillButton
tone="white"
size="sm"
onClick={() => handleAll("reject")}
disabled={!!busy}
>
{busy === "reject" && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Reject all
</PillButton>
{progress && (
<span className="text-xs font-serif text-gray-500">
{progress.done}/{progress.total}
</span>
)}
{onViewClick && first && (
<PillButton
tone="blue"
size="sm"
onClick={() =>
onViewClick(first.annotation, first.filename)
}
disabled={!!busy}
className="ml-auto"
>
View
</PillButton>
)}
</div>
);
}
/**
* Wraps the bulk accept/reject card and the per-edit EditCards in a single
* minimisable container. The bulk actions and summary stay visible in the
* header; the individual cards collapse via the chevron toggle.
*/
export function EditCardsSection({
pending,
filenameByDocId,
cards,
resolvedCount,
onViewClick,
onResolveStart,
onResolved,
onError,
}: {
pending: {
annotation: EditAnnotation;
filename: string;
}[];
filenameByDocId: Map<string, string>;
cards: ReactNode[];
resolvedCount: number;
onViewClick?: (ann: EditAnnotation, filename: string) => void;
onResolveStart?: (args: {
editId: string;
documentId: string;
verb: "accept" | "reject";
}) => void;
onResolved?: (args: {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
}) => void;
onError?: (args: {
editId: string;
documentId: string;
versionId: string | null;
message: string;
}) => void;
}) {
const [isOpen, setIsOpen] = useState(true);
if (cards.length === 0) return null;
const docCount = filenameByDocId.size;
const summary =
pending.length > 0
? docCount > 1
? `${pending.length} tracked changes across ${docCount} documents`
: `${pending.length} tracked ${pending.length === 1 ? "change" : "changes"}`
: docCount > 1
? `${resolvedCount} resolved tracked changes across ${docCount} documents`
: `${resolvedCount} resolved tracked ${resolvedCount === 1 ? "change" : "changes"}`;
return (
<div className="rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl overflow-hidden">
{/* Row 1: summary + chevron */}
<div className="flex items-center gap-2 px-3 pt-3">
<p className="flex-1 min-w-0 text-sm font-serif text-gray-700 truncate">
{summary}
</p>
<button
onClick={() => setIsOpen((v) => !v)}
aria-label={isOpen ? "Collapse edits" : "Expand edits"}
className="shrink-0 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
</button>
</div>
{/* Row 2: bulk action buttons */}
{pending.length > 0 && (
<div className="px-3 pt-3">
<BulkEditActions
pending={pending}
onViewClick={onViewClick}
onResolveStart={onResolveStart}
onResolved={onResolved}
onError={onError}
/>
</div>
)}
{/* Row 3: collapsible cards list */}
{isOpen && (
<div className="flex flex-col gap-2 px-3 pb-3 pt-3">
{cards}
</div>
)}
{!isOpen && <div className="pb-3" />}
</div>
);
}

View file

@ -0,0 +1,701 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChevronDown, Download, Loader2 } from "lucide-react";
import { supabase } from "@/app/lib/supabase";
import type { AssistantEvent } from "../../shared/types";
import { FileTypeIcon } from "../../shared/FileTypeIcon";
import { RESPONSE_GLASS_SURFACE, withoutMarkdownNode } from "./messageStyles";
const THINKING_PHRASES = [
"Thinking...",
"Pondering...",
"Analyzing...",
"Reviewing...",
"Reasoning...",
];
const REASONING_COLLAPSED_MAX_LINES = 6;
const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9;
// ---------------------------------------------------------------------------
// Event block primitives
// ---------------------------------------------------------------------------
function EventConnector() {
return (
<div className="absolute w-[1px] bg-gray-300 top-[14px] left-[3px] translate-x-[-50%] h-[calc(100%+10px)]" />
);
}
export function EventBlock({
showConnector,
isStreaming,
dotColor = "green",
children,
}: {
showConnector?: boolean;
isStreaming?: boolean;
dotColor?: "green" | "gray" | "red";
children: ReactNode;
}) {
const dotColorClass =
dotColor === "green"
? "bg-green-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
: dotColor === "red"
? "bg-red-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
: "bg-gray-300 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.35)]";
return (
<div className="flex items-start text-sm font-serif text-gray-500 relative">
{showConnector && <EventConnector />}
{isStreaming ? (
<div className="mt-2 w-1.5 h-1.5 shrink-0 rounded-full border border-gray-400 border-t-transparent animate-spin" />
) : (
<div
className={`mt-2 w-1.5 h-1.5 shrink-0 rounded-full ${dotColorClass}`}
/>
)}
<div className="ml-2 min-w-0 flex-1 whitespace-normal break-words">
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
export function ReasoningBlock({
text,
isStreaming,
showConnector,
}: {
text: string;
isStreaming: boolean;
showConnector?: boolean;
}) {
const [isContentOpen, setIsContentOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [userToggledContent, setUserToggledContent] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const [hasMeasured, setHasMeasured] = useState(false);
const [thinkingIndex, setThinkingIndex] = useState(0);
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isStreaming) return;
const interval = setInterval(() => {
setThinkingIndex((i) => (i + 1) % THINKING_PHRASES.length);
}, 2000);
return () => clearInterval(interval);
}, [isStreaming]);
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24;
const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES;
const nextOverflowing = el.scrollHeight > maxHeight + 2;
setIsOverflowing(nextOverflowing);
setHasMeasured(true);
if (!userToggledContent) setIsContentOpen(isStreaming);
if (!nextOverflowing) setIsExpanded(false);
}, [isStreaming, text, userToggledContent]);
const showContent = isContentOpen || isStreaming || !hasMeasured;
const isCollapsed = isContentOpen && isOverflowing && !isExpanded;
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor="gray"
>
<button
onClick={() => {
if (isStreaming) return;
setUserToggledContent(true);
setIsContentOpen((v) => !v);
}}
className="flex items-center text-sm font-serif text-gray-500 hover:text-gray-600 transition-colors"
>
<span className="font-medium">
{isStreaming
? THINKING_PHRASES[thinkingIndex]
: "Thought process"}
</span>
{!isStreaming && (
<ChevronDown
size={10}
className={`relative top-px ml-1 transition-transform duration-200 ${isContentOpen ? "" : "-rotate-90"}`}
/>
)}
</button>
{showContent && (
<div className="mt-2">
<div
className={`relative ${isCollapsed ? "overflow-hidden" : ""}`}
style={
isCollapsed
? {
maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`,
}
: undefined
}
>
<div
ref={contentRef}
className="text-sm font-serif text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: (props) => (
<code
className="font-serif text-gray-600"
{...withoutMarkdownNode(props)}
/>
),
}}
>
{text}
</ReactMarkdown>
</div>
{isCollapsed && (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" />
<button
type="button"
onClick={() => setIsExpanded(true)}
className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label="Expand thought process"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
{isOverflowing && isContentOpen && isExpanded && (
<button
type="button"
onClick={() => setIsExpanded(false)}
className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600"
aria-label="Minimise thought process"
>
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
)}
</div>
)}
</EventBlock>
);
}
export function DocReadBlock({
filename,
onClick,
showConnector,
isStreaming,
}: {
filename: string;
onClick?: () => void;
showConnector?: boolean;
isStreaming?: boolean;
}) {
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor="green"
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0 font-medium">
{isStreaming ? "Reading" : "Read"}
</span>
{isStreaming ? (
<span className="flex min-w-0 items-center gap-1.5">
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
<span className="truncate">{filename}...</span>
</span>
) : onClick ? (
<button
onClick={onClick}
className="flex min-w-0 items-center gap-1.5 text-left transition-colors hover:text-gray-700 cursor-pointer"
>
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
<span className="truncate">{filename}</span>
</button>
) : (
<span className="flex min-w-0 items-center gap-1.5">
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
<span className="truncate">{filename}</span>
</span>
)}
</div>
</EventBlock>
);
}
export function DocFindBlock({
filename,
query,
totalMatches,
isStreaming,
showConnector,
}: {
filename: string;
query: string;
totalMatches: number;
isStreaming?: boolean;
showConnector?: boolean;
}) {
const matchSuffix = isStreaming
? ""
: ` (${totalMatches} ${totalMatches === 1 ? "match" : "matches"})`;
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={totalMatches > 0 ? "green" : "gray"}
>
<span className="font-medium">
{isStreaming ? "Finding" : "Found"}
</span>{" "}
<span>
&ldquo;{query}&rdquo;{matchSuffix}
<span className="ml-1 text-gray-400">in {filename}</span>
{isStreaming && "..."}
</span>
</EventBlock>
);
}
export function DocCreatedBlock({
filename,
showConnector,
isStreaming,
}: {
filename: string;
showConnector?: boolean;
isStreaming?: boolean;
}) {
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor="green"
>
<span className="font-medium">
{isStreaming ? "Creating" : "Created"}
</span>{" "}
<span>{isStreaming ? `${filename}...` : filename}</span>
</EventBlock>
);
}
export function DocReplicatedBlock({
filename,
count,
showConnector,
isStreaming,
hasError,
}: {
filename: string;
/**
* How many consecutive replicates of this same source got collapsed
* into this block. 1; only rendered when > 1.
*/
count: number;
showConnector?: boolean;
isStreaming?: boolean;
hasError?: boolean;
}) {
const label = isStreaming ? "Replicating" : "Replicated";
const suffix =
!isStreaming && count > 1
? ` ${count} times`
: isStreaming
? "..."
: "";
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={hasError ? "red" : "green"}
>
<span className="font-medium">{label}</span>{" "}
<span>
{filename}
{suffix}
</span>
</EventBlock>
);
}
export function DocDownloadBlock({
filename,
download_url,
onOpen,
isReloading = false,
versionNumber,
}: {
filename: string;
download_url: string;
onOpen?: () => void;
isReloading?: boolean;
versionNumber?: number | null;
}) {
const hasVersion =
typeof versionNumber === "number" &&
Number.isFinite(versionNumber) &&
versionNumber > 0;
const extMatch = filename.match(/\.(\w+)$/);
const ext = extMatch ? extMatch[1].toUpperCase() : "FILE";
const rawBasename = extMatch
? filename.slice(0, -extMatch[0].length)
: filename;
// Strip any legacy "[Edited V3]" suffix that may still be baked into
// older saved download filenames — the version is surfaced as a
// separate tag now.
const basename = rawBasename.replace(/\s*\[Edited V\d+\]\s*$/, "").trim();
// Only backend-relative URLs are accepted. The download fetch carries
// the user's bearer token, so any absolute URL from tool output is
// refused to keep the token from leaking off-origin.
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
const isSafeHref = download_url.startsWith("/");
const href = isSafeHref ? `${API_BASE}${download_url}` : null;
const [busy, setBusy] = useState(false);
const handleDownload = async (e?: {
stopPropagation?: () => void;
preventDefault?: () => void;
}) => {
e?.stopPropagation?.();
e?.preventDefault?.();
if (busy || isReloading || !href) return;
setBusy(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const resp = await fetch(href, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} finally {
setBusy(false);
}
};
const spinning = busy || isReloading;
const body = (
<div className="flex items-center gap-3 px-4 py-3 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<p className="text-base font-serif text-gray-900 text-wrap">
{basename}
</p>
{hasVersion && (
<span className="shrink-0 inline-flex items-center rounded-md border border-white/70 bg-white/55 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl">
V{versionNumber}
</span>
)}
</div>
<p className="text-xs text-blue-500 mt-0.5">{ext}</p>
</div>
</div>
);
const downloadIcon = spinning ? (
<div
aria-disabled
className="shrink-0 flex items-center bg-white/25 px-6 text-gray-400 cursor-not-allowed"
>
<Loader2 size={13} className="animate-spin" />
</div>
) : (
<button
type="button"
onClick={handleDownload}
className="shrink-0 flex items-center bg-white/25 px-6 text-gray-500 transition-colors hover:bg-white/55 hover:text-gray-700 cursor-pointer"
>
<Download size={13} />
</button>
);
if (onOpen) {
return (
<div
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
>
<button
type="button"
onClick={onOpen}
className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer"
>
{body}
</button>
{downloadIcon}
</div>
);
}
if (spinning) {
return (
<div
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
>
{body}
{downloadIcon}
</div>
);
}
return (
<div
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
>
<button
type="button"
onClick={handleDownload}
className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer"
>
{body}
</button>
{downloadIcon}
</div>
);
}
export function WorkflowAppliedBlock({
title,
showConnector,
onClick,
}: {
title: string;
showConnector?: boolean;
onClick?: () => void;
}) {
return (
<EventBlock showConnector={showConnector} dotColor="green">
<span className="font-medium">Applied Workflow</span>{" "}
{onClick ? (
<button
onClick={onClick}
className="text-left hover:text-gray-700 transition-colors cursor-pointer"
>
{title}
</button>
) : (
<span>{title}</span>
)}
</EventBlock>
);
}
export function AskInputsBlock({
event,
response,
showConnector,
}: {
event: Extract<AssistantEvent, { type: "ask_inputs" }>;
response?: Extract<AssistantEvent, { type: "ask_inputs_response" }>;
showConnector?: boolean;
}) {
const responseById = new Map(
response?.responses.map((item) => [item.id, item]) ?? [],
);
return (
<EventBlock
showConnector={showConnector}
dotColor={response ? "green" : "gray"}
>
<p className="font-medium text-gray-600">
{response ? "Asked for input" : "Asking for input"}
</p>
<div className="mt-2 space-y-2 text-gray-800">
{event.items.map((item, index) => {
const itemResponse = responseById.get(item.id);
const responseText = (() => {
if (!itemResponse) return null;
if (itemResponse.skipped) return "Skipped";
if (itemResponse.kind === "choice") {
return itemResponse.answer ?? "";
}
const filenames = itemResponse.filenames;
return filenames.length
? filenames.join(", ")
: "No documents attached";
})();
return (
<div key={item.id}>
<p className="text-xs text-gray-500">
{index + 1}.{" "}
{item.kind === "choice"
? "Question"
: "Documents"}
</p>
<p className="mt-0.5">
{item.kind === "choice"
? item.question
: item.document_types.join(", ") ||
"Documents requested"}
</p>
{responseText !== null && (
<p className="mt-0.5 text-gray-600">
{responseText}
</p>
)}
</div>
);
})}
</div>
</EventBlock>
);
}
export type CourtListenerBlockItem = {
caseName: string | null;
citation: string | null;
dateFiled?: string | null;
url?: string | null;
query?: string;
totalMatches?: number;
hasError?: boolean;
};
export function CourtListenerBlock({
label,
detail,
isStreaming,
hasError,
showConnector,
items,
}: {
label: string;
detail?: string;
isStreaming?: boolean;
hasError?: boolean;
showConnector?: boolean;
items?: CourtListenerBlockItem[];
}) {
const [isOpen, setIsOpen] = useState(false);
const hasItems = !!items && items.length > 0;
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={hasError ? "red" : "green"}
>
{hasItems ? (
<button
onClick={() => setIsOpen((v) => !v)}
className="text-left hover:text-gray-700 transition-colors inline-flex items-center"
>
<span className="font-medium">{label}</span>
{detail ? <span>&nbsp;{detail}</span> : null}
{isStreaming ? <span>...</span> : null}
<ChevronDown
size={10}
className={`relative top-px ml-1 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
</button>
) : (
<>
<span className="font-medium">{label}</span>
{detail ? <span> {detail}</span> : null}
{isStreaming ? <span>...</span> : null}
</>
)}
{isOpen && hasItems && (
<ul className="mt-2 flex flex-col gap-1 text-sm font-serif text-gray-500">
{items!.map((item, idx) => {
const label = [item.caseName, item.citation]
.filter(Boolean)
.join(", ");
const primary = label || item.url || "Unknown case";
const searchText = item.query
? `Searched for "${item.query}" in ${primary}${
typeof item.totalMatches === "number"
? ` (${item.totalMatches} ${
item.totalMatches === 1
? "match"
: "matches"
})`
: ""
}`
: null;
return (
<li key={idx}>
<div
className={
item.hasError ? "text-red-500" : ""
}
>
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="hover:text-gray-700 hover:underline underline-offset-2"
>
{searchText ?? primary}
</a>
) : searchText ? (
<span>{searchText}</span>
) : (
<span>{primary}</span>
)}
</div>
</li>
);
})}
</ul>
)}
</EventBlock>
);
}
export function DocEditedBlock({
filename,
showConnector,
isStreaming,
hasError,
}: {
filename: string;
showConnector?: boolean;
isStreaming?: boolean;
hasError?: boolean;
}) {
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={hasError ? "red" : "green"}
>
<span className="font-medium">
{isStreaming ? "Editing" : hasError ? "Edit failed" : "Edited"}
</span>{" "}
<span>{isStreaming ? `${filename}...` : filename}</span>
</EventBlock>
);
}

View file

@ -0,0 +1,278 @@
import type { RefObject } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkMath from "remark-math";
import remarkGfm from "remark-gfm";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import type { AssistantEvent, Citation } from "../../shared/types";
import { RESPONSE_GLASS_ANNOTATION, withoutMarkdownNode } from "./messageStyles";
import { citationTooltip } from "./CitationSources";
import { internalCaseHref } from "./citationUtils";
export function MarkdownContent({
text,
inlineCitationTargets,
caseCitations,
caseOpinions,
onCitationClick,
onCaseClick,
divRef,
}: {
text: string;
inlineCitationTargets: Citation[];
caseCitations: Map<
string,
Extract<AssistantEvent, { type: "case_citation" }>
>;
caseOpinions: Map<
number,
Extract<AssistantEvent, { type: "case_opinions" }>["case"]
>;
onCitationClick?: (c: Citation) => void;
onCaseClick?: (
c: Extract<AssistantEvent, { type: "case_citation" }>,
) => void;
divRef?: RefObject<HTMLDivElement | null>;
}) {
function findCaseCitation(href: string) {
return caseCitations.get(internalCaseHref(href) ?? "");
}
return (
<div
ref={divRef}
className="text-gray-900 mb-4 text-base prose prose-sm max-w-none font-serif"
>
<ReactMarkdown
remarkPlugins={[
[remarkMath, { singleDollarTextMath: false }],
remarkGfm,
]}
rehypePlugins={[rehypeKatex]}
urlTransform={(url) =>
/^us-case-\d+$/.test(url) ? url : defaultUrlTransform(url)
}
components={{
table: (props) => (
<div className="overflow-x-auto my-4 rounded-lg">
<table
className="min-w-full divide-y divide-gray-300 overflow-hidden"
{...withoutMarkdownNode(props)}
/>
</div>
),
thead: (props) => (
<thead
className="bg-gray-100"
{...withoutMarkdownNode(props)}
/>
),
tbody: (props) => (
<tbody
className="divide-y divide-gray-200"
{...withoutMarkdownNode(props)}
/>
),
tr: (props) => <tr {...withoutMarkdownNode(props)} />,
th: (props) => (
<th
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
{...withoutMarkdownNode(props)}
/>
),
td: (props) => (
<td
className="whitespace-normal px-3 py-4 text-sm text-gray-900"
{...withoutMarkdownNode(props)}
/>
),
h1: (props) => (
<h1
className="mt-6 mb-4 text-3xl font-serif font-semibold"
{...withoutMarkdownNode(props)}
/>
),
h2: (props) => (
<h2
className="mt-5 mb-3 text-2xl font-serif font-semibold"
{...withoutMarkdownNode(props)}
/>
),
h3: (props) => (
<h3
className="text-xl font-semibold mt-4 mb-2"
{...withoutMarkdownNode(props)}
/>
),
h4: (props) => (
<h4
className="text-lg font-semibold mt-4 mb-2"
{...withoutMarkdownNode(props)}
/>
),
p: ({ node, ...props }) => {
const parent =
node && typeof node === "object" && "parent" in node
? (node as { parent?: { type?: string } })
.parent
: undefined;
if (parent?.type === "listItem") {
return (
<p
className="inline leading-7 m-0"
{...props}
/>
);
}
return <p className="mb-4 leading-7" {...props} />;
},
ul: (props) => (
<ul
className="list-disc list-outside mb-4 pl-6"
{...withoutMarkdownNode(props)}
/>
),
ol: (props) => (
<ol
className="list-decimal list-outside mb-4 pl-6"
{...withoutMarkdownNode(props)}
/>
),
li: (props) => (
<li
className="mb-2 leading-7"
{...withoutMarkdownNode(props)}
/>
),
strong: (props) => (
<strong
className="font-semibold"
{...withoutMarkdownNode(props)}
/>
),
em: (props) => (
<em className="italic" {...withoutMarkdownNode(props)} />
),
code: (props) => {
const { children, ...codeProps } =
withoutMarkdownNode(props);
const text = String(children);
const citMatch = text.match(/^§(\d+)§$/);
if (citMatch) {
const idx = parseInt(citMatch[1]);
const annotation = inlineCitationTargets[idx];
if (annotation) {
const tooltipText = citationTooltip(annotation);
return (
<button
onClick={() =>
onCitationClick?.(annotation)
}
data-citation-ref={annotation.ref}
className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`}
title={tooltipText}
>
{annotation.ref}
</button>
);
}
}
return (
<code
className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-serif"
{...codeProps}
>
{children}
</code>
);
},
blockquote: (props) => (
<blockquote
className="border-l-4 border-gray-300 pl-4 italic my-4"
{...withoutMarkdownNode(props)}
/>
),
a: (props) => {
const { href, children, ...anchorProps } =
withoutMarkdownNode(props);
if (href) {
const isInternalCaseHref = !!internalCaseHref(href);
const citation = findCaseCitation(href);
if (citation && onCaseClick) {
return (
<button
type="button"
onClick={() =>
onCaseClick({
...citation,
case:
citation.cluster_id !== null
? caseOpinions.get(
citation.cluster_id,
)
: undefined,
})
}
className="text-left text-blue-600 hover:text-blue-700 underline"
>
{children}
</button>
);
}
if (citation) {
return (
<a
href={citation.url}
className="text-blue-600 hover:text-blue-700 underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
}
if (isInternalCaseHref) {
return (
<span className="text-blue-600 underline">
{children}
</span>
);
}
return (
<a
href={href}
className="text-blue-600 hover:text-blue-700 underline"
target="_blank"
rel="noopener noreferrer"
{...anchorProps}
>
{children}
</a>
);
}
return (
<a
href={href}
className="text-blue-600 hover:text-blue-700 underline"
target="_blank"
rel="noopener noreferrer"
{...anchorProps}
>
{children}
</a>
);
},
hr: (props) => (
<hr
className="my-6 border-gray-200"
{...withoutMarkdownNode(props)}
/>
),
}}
>
{text}
</ReactMarkdown>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { MikeIcon } from "@/app/components/chat/mike-icon";
export type StatusState = "active" | "error" | null;
export function ResponseStatus({ status }: { status: StatusState }) {
const [showDone, setShowDone] = useState(false);
const [doneVisible, setDoneVisible] = useState(false);
const wasActiveRef = useRef(false);
const isActive = status === "active";
const isError = status === "error";
useEffect(() => {
const wasActive = wasActiveRef.current;
wasActiveRef.current = isActive;
let raf = 0;
let doneTimeout = 0;
if (wasActive && !isActive) {
raf = window.requestAnimationFrame(() => {
setShowDone(true);
setDoneVisible(true);
doneTimeout = window.setTimeout(
() => setDoneVisible(false),
1500,
);
});
} else if (!wasActive && isActive) {
raf = window.requestAnimationFrame(() => {
setShowDone(false);
setDoneVisible(false);
});
}
return () => {
window.cancelAnimationFrame(raf);
if (doneTimeout) window.clearTimeout(doneTimeout);
};
}, [isActive]);
return (
<div className="w-full h-9 flex items-center mb-2">
<MikeIcon
spin={isActive}
done={showDone && doneVisible}
error={isError}
mike={!isError && !(showDone && doneVisible)}
size={22}
/>
</div>
);
}

View file

@ -0,0 +1,36 @@
import type { Citation } from "../../shared/types";
export function preprocessCitations(
text: string,
citations: Citation[],
inlineCitationTargets: Citation[],
): string {
// Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by citations.
return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr) => {
const refs = (refsStr as string)
.split(",")
.map((s: string) => parseInt(s.trim(), 10));
const tokens = refs.flatMap((ref: number) => {
const citation = citations.find((a) => a.ref === ref);
if (!citation) return [];
const idx = inlineCitationTargets.length;
inlineCitationTargets.push(citation);
return [`\`§${idx}§\`\u200B`];
});
return tokens.length > 0 ? tokens.join("") : full;
});
}
// ---------------------------------------------------------------------------
// Markdown renderer (shared config)
// ---------------------------------------------------------------------------
export function internalCaseHref(
value: string | number | null | undefined,
): string | null {
if (typeof value === "number") return `us-case-${value}`;
if (!value) return null;
const match = value.match(/^us-case-(\d+)$/);
return match ? `us-case-${match[1]}` : null;
}

View file

@ -0,0 +1,33 @@
import type { AssistantEvent } from "../../shared/types";
export function eventErrorMessage(event: AssistantEvent): string | null {
if (event.type === "error") return event.message;
if ("error" in event && typeof event.error === "string" && event.error) {
return event.error;
}
return null;
}
export function toolCallLabel(name: string): string {
if (name === "ask_inputs") return "Asking for input...";
if (name === "generate_docx") return "Creating document...";
if (name === "generate_excel") return "Creating spreadsheet...";
if (name === "generate_ppt") return "Creating presentation...";
if (name === "edit_document") return "Editing document...";
if (name === "read_document") return "Reading document...";
if (name === "fetch_documents") return "Reading documents...";
if (name === "find_in_document") return "Searching document...";
if (name === "replicate_document") return "Copying document...";
if (name === "read_workflow") return "Loading workflow...";
if (name === "list_workflows") return "Loading workflows...";
if (name === "list_documents") return "Loading documents...";
if (name === "courtlistener_search_case_law")
return "Searching case law...";
if (name === "courtlistener_get_cases") return "Fetching cases...";
if (name === "courtlistener_find_in_case") return "Searching case...";
if (name === "courtlistener_read_case") return "Reading case...";
if (name === "courtlistener_verify_citations")
return "Verifying citations...";
if (name.startsWith("mcp_")) return "Using connector...";
return name ? `Running ${name}...` : "Working...";
}

View file

@ -0,0 +1,12 @@
export const RESPONSE_GLASS_SURFACE =
"rounded-xl border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl";
export const RESPONSE_GLASS_ANNOTATION =
"inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950";
export function withoutMarkdownNode<P extends { node?: unknown }>(
props: P,
): Omit<P, "node"> {
const { node, ...rest } = props;
void node;
return rest;
}

View file

@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
/**
* Hide jitter from arrival of streamed text chunks by revealing characters at
* a smooth, rate-paced clip rather than rendering every chunk verbatim.
*
* Returns a prefix of `text` whose length grows over time toward the full
* length. When `active` is false (stream ended, message replayed from
* history, etc.), snaps to the full text immediately.
*
* Rate adapts to backlog: small backlogs reveal at a 40 cps floor; large
* backlogs catch up within ~0.4s, so the smoothing never lags noticeably
* behind the server.
*/
export function useSmoothedReveal(text: string, active: boolean): string {
const [revealedInt, setRevealedInt] = useState(text.length);
const revealedFloat = useRef<number>(text.length);
useEffect(() => {
if (!active) {
revealedFloat.current = text.length;
return;
}
// Defensive clamp in case the text was edited / replaced shorter.
if (revealedFloat.current > text.length) {
revealedFloat.current = text.length;
}
let lastTick = performance.now();
let raf = 0;
let cancelled = false;
const step = (now: number) => {
if (cancelled) return;
const dt = Math.max(0, (now - lastTick) / 1000);
lastTick = now;
const target = text.length;
const prev = revealedFloat.current;
if (prev < target) {
const backlog = target - prev;
const cps = Math.max(40, backlog / 0.4);
const next = Math.min(target, prev + cps * dt);
revealedFloat.current = next;
const nextInt = Math.floor(next);
setRevealedInt((cur) => (cur === nextInt ? cur : nextInt));
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
}, [text.length, active]);
return text.slice(0, Math.min(revealedInt, text.length));
}

View file

@ -0,0 +1,221 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { AlertCircle, Upload, Loader2, X } from "lucide-react";
import {
uploadStandaloneDocument,
uploadProjectDocument,
addDocumentToProject,
} from "@/app/lib/mikeApi";
import type { Document } from "../shared/types";
import { FileDirectory } from "../shared/FileDirectory";
import {
useDirectoryData,
invalidateDirectoryCache,
} from "../shared/useDirectoryData";
import { Modal } from "./Modal";
import {
SUPPORTED_DOCUMENT_ACCEPT,
formatUnsupportedDocumentWarning,
partitionSupportedDocumentFiles,
} from "@/app/lib/documentUploadValidation";
export { invalidateDirectoryCache };
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: Document[], projectId?: string) => void;
breadcrumb: string[];
allowMultiple?: boolean;
projectId?: string;
}
export function AddDocumentsModal({
open,
onClose,
onSelect,
breadcrumb,
allowMultiple = true,
projectId,
}: Props) {
const { loading, standaloneDocuments, projects } = useDirectoryData(open);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setSelectedIds(new Set());
setExtraUploadedDocs([]);
setUploadingFilenames([]);
setUploadWarning(null);
}, [open]);
if (!open) return null;
const allStandalone = [
...extraUploadedDocs.filter(
(u) => !standaloneDocuments.some((d) => d.id === u.id),
),
...standaloneDocuments,
];
const availableProjects = projects
.filter((p) => p.id !== projectId)
.map((p) => ({
...p,
documents: p.documents || [],
}));
const allDocs = [
...allStandalone,
...availableProjects.flatMap((p) => p.documents || []),
];
async function handleConfirm() {
const selected = allDocs.filter((d) => selectedIds.has(d.id));
if (projectId) {
const toAssign = selected.filter((d) => d.project_id !== projectId);
const alreadyHere = selected.filter(
(d) => d.project_id === projectId,
);
if (toAssign.length > 0) {
setUploading(true);
try {
const assigned = await Promise.all(
toAssign.map((d) =>
addDocumentToProject(projectId, d.id),
),
);
onSelect([...alreadyHere, ...assigned], projectId);
} catch (err) {
console.error("Failed to assign documents:", err);
} finally {
setUploading(false);
}
} else {
onSelect(alreadyHere, projectId);
}
onClose();
return;
}
const projectIds = new Set(
selected.map((d) => d.project_id).filter(Boolean),
);
const singleProjectId =
projectIds.size === 1 ? [...projectIds][0]! : undefined;
onSelect(selected, singleProjectId);
onClose();
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
const { supported, unsupported } =
partitionSupportedDocumentFiles(files);
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
if (supported.length === 0) {
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
setUploadingFilenames(supported.map((file) => file.name));
setUploading(true);
try {
const uploaded = await Promise.all(
supported.map((f) =>
projectId
? uploadProjectDocument(projectId, f)
: uploadStandaloneDocument(f),
),
);
invalidateDirectoryCache();
setExtraUploadedDocs((prev) => [...uploaded, ...prev]);
uploaded.forEach((d) =>
setSelectedIds((prev) => new Set([...prev, d.id])),
);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
setUploadingFilenames([]);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={breadcrumb}
secondaryAction={{
label: uploading ? "Uploading…" : "Upload",
icon: uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
),
onClick: () => fileInputRef.current?.click(),
disabled: uploading,
}}
footerStatus={
selectedIds.size > 0 ? (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
) : null
}
primaryAction={{
label: uploading ? "Saving…" : "Confirm",
onClick: handleConfirm,
disabled: selectedIds.size === 0 || uploading,
}}
>
<input
ref={fileInputRef}
type="file"
accept={SUPPORTED_DOCUMENT_ACCEPT}
multiple
className="hidden"
onChange={handleUpload}
/>
{uploadWarning && (
<div className="mb-2 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-xs text-gray-900">
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" />
<span className="min-w-0 flex-1">{uploadWarning}</span>
<button
type="button"
onClick={() => setUploadWarning(null)}
className="shrink-0 rounded p-0.5 text-black hover:bg-gray-100"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={allStandalone}
directoryProjects={availableProjects}
loading={loading}
selectedIds={selectedIds}
onChange={setSelectedIds}
allowMultiple={allowMultiple}
emptyMessage="No documents yet"
uploadingFilenames={uploadingFilenames}
searchable
searchAutoFocus
searchNoResultsMessage="No matches found"
showProjectTabs
/>
</div>
</Modal>
);
}

View file

@ -0,0 +1,250 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Loader2, Upload, X } from "lucide-react";
import { SearchBar } from "@/app/components/ui/search-bar";
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
import type { Document } from "../shared/types";
import { DocFileIcon } from "../shared/FileDirectory";
import { VersionChip } from "../shared/VersionChip";
import { Modal } from "./Modal";
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: Document[]) => void;
breadcrumb: string[];
projectId: string;
/** Docs already in the target list — rendered checked + disabled. */
excludeDocIds?: Set<string>;
allowMultiple?: boolean;
}
function formatDate(iso: string | null) {
if (!iso) return null;
return new Date(iso).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function AddProjectDocsModal({
open,
onClose,
onSelect,
breadcrumb,
projectId,
excludeDocIds,
allowMultiple = true,
}: Props) {
const [docs, setDocs] = useState<Document[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setSearch("");
setSelectedIds(new Set());
let cancelled = false;
setLoading(true);
getProject(projectId)
.then((p) => {
if (!cancelled) setDocs(p.documents ?? []);
})
.catch(() => {
if (!cancelled) setDocs([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, projectId]);
if (!open) return null;
const q = search.toLowerCase().trim();
const filtered = q
? docs.filter((d) => d.filename.toLowerCase().includes(q))
: docs;
const isExcluded = (id: string) => !!excludeDocIds?.has(id);
function toggle(id: string) {
if (isExcluded(id)) return;
if (!allowMultiple) {
setSelectedIds(new Set([id]));
return;
}
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function handleConfirm() {
const selected = docs.filter((d) => selectedIds.has(d.id));
onSelect(selected);
onClose();
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) => uploadProjectDocument(projectId, f)),
);
setDocs((prev) => [...uploaded, ...prev]);
setSelectedIds((prev) => {
const next = new Set(prev);
uploaded.forEach((d) => next.add(d.id));
return next;
});
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={breadcrumb}
secondaryAction={{
label: uploading ? "Uploading…" : "Upload",
icon: uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
),
onClick: () => fileInputRef.current?.click(),
disabled: uploading,
}}
footerStatus={
selectedIds.size > 0 ? (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
) : null
}
primaryAction={{
label: "Confirm",
onClick: handleConfirm,
disabled: selectedIds.size === 0 || uploading,
}}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
multiple
className="hidden"
onChange={handleUpload}
/>
<div className="pt-1 pb-2">
<SearchBar
value={search}
onValueChange={setSearch}
placeholder="Search..."
autoFocus
/>
</div>
{/* File list */}
<div className="min-h-0 flex-1 overflow-y-auto">
{loading ? (
<div className="rounded-sm border border-gray-100 overflow-hidden">
{[60, 45, 75, 55, 40].map((w, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-2"
>
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-100 animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
</div>
) : filtered.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">
{q ? "No matches found" : "No documents in this project"}
</p>
) : (
<div className="rounded-sm border border-gray-100 overflow-hidden">
{filtered.map((doc) => {
const excluded = isExcluded(doc.id);
const checked = excluded || selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
disabled={excluded}
onClick={() => toggle(doc.id)}
className={`w-full flex items-center gap-2 rounded-md px-2 py-2 text-xs text-left transition-all ${
excluded
? "opacity-50 cursor-not-allowed"
: checked
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
checked
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{checked && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`flex-1 truncate ${
checked
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
{excluded && (
<span className="text-[10px] text-gray-400 shrink-0">
Already added
</span>
)}
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(doc.created_at)}
</span>
)}
</button>
);
})}
</div>
)}
</div>
</Modal>
);
}

View file

@ -0,0 +1,203 @@
"use client";
import { createPortal } from "react-dom";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { X } from "lucide-react";
import { PillButton } from "@/app/components/ui/pill-button";
import { cn } from "@/app/lib/utils";
type ModalSize = "sm" | "md" | "lg" | "xl";
type ModalAction = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
label: ReactNode;
icon?: ReactNode;
variant?: "primary" | "secondary" | "danger";
};
interface ModalProps {
open: boolean;
onClose: () => void;
children: ReactNode;
breadcrumbs?: ReactNode[];
headerAction?: ReactNode;
size?: ModalSize;
className?: string;
footerStatus?: ReactNode;
primaryAction?: ModalAction;
secondaryAction?: ModalAction;
cancelAction?: ModalAction | false;
}
const sizeClassName: Record<ModalSize, string> = {
sm: "max-w-md",
md: "max-w-xl",
lg: "max-w-2xl",
xl: "max-w-4xl",
};
export function Modal({
open,
onClose,
children,
breadcrumbs,
headerAction,
size = "lg",
className,
footerStatus,
primaryAction,
secondaryAction,
cancelAction,
}: ModalProps) {
const hasHeader = breadcrumbs?.length;
const hasFooter =
footerStatus ||
primaryAction ||
secondaryAction ||
cancelAction;
const resolvedCancelAction = cancelAction;
if (!open) return null;
return createPortal(
<div
className={cn(
"fixed inset-0 z-[200] flex items-center justify-center px-4",
"bg-white/10 backdrop-blur-[2px]",
)}
onClick={onClose}
>
<div
className={cn(
"w-full rounded-3xl flex h-[600px] flex-col",
sizeClassName[size],
"border border-white/70 bg-gray-50/95 shadow-[0_14px_40px_rgba(15,23,42,0.101),0_5px_14px_rgba(15,23,42,0.067)] backdrop-blur-3xl",
className,
)}
onClick={(e) => e.stopPropagation()}
>
{hasHeader && (
<div className="flex items-center justify-between gap-3 p-4 pl-5">
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs leading-none text-gray-400">
{breadcrumbs?.map((segment, index) => (
<span
key={index}
className="flex items-center gap-1.5"
>
{index > 0 && <span></span>}
<span
className={cn(
"truncate",
index ===
(breadcrumbs?.length ?? 0) -
1 && "text-gray-700",
)}
>
{segment}
</span>
</span>
))}
</div>
{headerAction}
</div>
<button
onClick={onClose}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
aria-label="Close"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Body never scrolls itself (so children's edge shadows are
never clipped by the header/footer). Content that can exceed
the modal height wraps its scrollable region in an inner
`overflow-y-auto` div. */}
<div className="flex min-h-0 flex-1 flex-col px-5">
{children}
</div>
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 p-3",
secondaryAction
? "justify-between"
: "justify-end",
"border-t border-white/60",
)}
>
{secondaryAction && (
<div className="flex min-w-0 items-center gap-2">
<ModalActionButton
action={secondaryAction}
fallbackVariant="secondary"
/>
</div>
)}
<div className="flex items-center gap-2">
{footerStatus}
{resolvedCancelAction && (
<ModalActionButton
action={resolvedCancelAction}
fallbackVariant="cancel"
/>
)}
{primaryAction && (
<ModalActionButton
action={primaryAction}
fallbackVariant="primary"
/>
)}
</div>
</div>
)}
</div>
</div>,
document.body,
);
}
function ModalActionButton({
action,
fallbackVariant,
}: {
action: ModalAction;
fallbackVariant: "primary" | "secondary" | "danger" | "cancel";
}) {
const {
label,
icon,
variant = fallbackVariant === "cancel" ? "secondary" : fallbackVariant,
...props
} = action;
if (fallbackVariant === "cancel") {
return (
<button
type="button"
className="px-2 py-1.5 text-sm text-gray-500 transition-colors hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
{...props}
>
{label}
</button>
);
}
const tone =
variant === "danger"
? "danger"
: fallbackVariant === "secondary" && variant === "secondary"
? "blue"
: variant === "primary"
? "black"
: "white";
return (
<PillButton tone={tone} size="normal" {...props}>
{icon}
{label}
</PillButton>
);
}

View file

@ -0,0 +1,35 @@
"use client";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { cn } from "@/app/lib/utils";
type ModalFieldLabelProps = ComponentPropsWithoutRef<"label"> & {
children: ReactNode;
as?: "label" | "p" | "span";
};
export function ModalFieldLabel({
as = "label",
children,
className,
...props
}: ModalFieldLabelProps) {
const classes = cn(
"mb-2 block text-xs font-medium text-gray-700",
className,
);
if (as === "p") {
return <p className={classes}>{children}</p>;
}
if (as === "span") {
return <span className={classes}>{children}</span>;
}
return (
<label className={classes} {...props}>
{children}
</label>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/app/lib/utils";
export interface SegmentedToggleOption<T extends string> {
value: T;
label: string;
icon?: LucideIcon;
}
interface ModalSegmentedToggleProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: SegmentedToggleOption<T>[];
disabled?: boolean;
size?: "sm" | "md";
className?: string;
}
export function ModalSegmentedToggle<T extends string>({
value,
onChange,
options,
disabled = false,
size = "md",
className,
}: ModalSegmentedToggleProps<T>) {
return (
<div
className={cn(
"inline-grid gap-1 rounded-full bg-gray-100",
size === "sm" ? "h-8 p-1" : "h-9 p-1",
className,
)}
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
{options.map((option) => {
const Icon = option.icon;
const active = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
disabled={disabled}
aria-pressed={active}
className={cn(
"flex h-full items-center justify-center rounded-full text-xs transition-all disabled:cursor-not-allowed disabled:opacity-60",
size === "sm" ? "gap-1 px-3" : "gap-1.5 px-3",
active
? "bg-white/80 text-gray-900 shadow-[0_5px_16px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.62)] backdrop-blur-xl"
: "text-gray-500 hover:text-gray-700",
)}
>
{Icon && (
<Icon
className={
size === "sm" ? "h-2.5 w-2.5" : "h-3 w-3"
}
/>
)}
{option.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { ChevronDown, type LucideIcon } from "lucide-react";
import { cn } from "@/app/lib/utils";
export type ModalSelectOption =
| string
| {
value: string;
label: string;
icon?: LucideIcon;
iconClassName?: string;
};
interface ModalSelectProps {
id: string;
value: string;
options: readonly ModalSelectOption[];
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
menuClassName?: string;
}
function normalizeOption(option: ModalSelectOption) {
return typeof option === "string"
? { value: option, label: option }
: option;
}
export function ModalSelect({
id,
value,
options,
onChange,
placeholder = "Select...",
disabled = false,
open,
onOpenChange,
className,
menuClassName,
}: ModalSelectProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = open ?? internalOpen;
const normalizedOptions = options.map(normalizeOption);
const selected = normalizedOptions.find((option) => option.value === value);
const hasValue = value.trim().length > 0;
function setOpen(next: boolean) {
onOpenChange?.(next);
if (open === undefined) {
setInternalOpen(next);
}
}
function handleSelect(nextValue: string) {
setOpen(false);
onChange(nextValue);
}
return (
<div className="relative">
<button
id={id}
type="button"
onClick={() => setOpen(!isOpen)}
disabled={disabled}
className={cn(
"flex h-10 w-full items-center justify-between rounded-xl border border-white/70 bg-white/55 px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors hover:bg-white/70 focus:bg-white/70 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className="flex min-w-0 items-center gap-2">
{selected?.icon && (
<selected.icon
className={cn(
"h-3.5 w-3.5 shrink-0",
selected.iconClassName,
)}
/>
)}
<span
className={cn(
"truncate",
!selected && !hasValue && "text-gray-400",
)}
>
{selected?.label ?? (hasValue ? value : placeholder)}
</span>
</span>
<ChevronDown
className={cn(
"ml-2 h-3.5 w-3.5 shrink-0 text-gray-400 transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen && !disabled && (
<div
role="listbox"
aria-labelledby={id}
className={cn(
"absolute left-0 top-full z-30 mt-1 max-h-56 w-full overflow-y-auto rounded-2xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_12px_32px_rgba(15,23,42,0.156),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl",
menuClassName,
)}
>
{normalizedOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
onClick={() => handleSelect(option.value)}
className={cn(
"flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-all hover:bg-gray-100/70",
option.value === value
? "bg-gray-100 text-gray-900"
: "text-gray-700",
)}
>
<span className="flex min-w-0 items-center gap-2">
{option.icon && (
<option.icon
className={cn(
"h-3.5 w-3.5 shrink-0",
option.iconClassName,
)}
/>
)}
<span className="truncate">
{option.label}
</span>
</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "@/app/lib/utils";
type ModalTextInputVariant = "glass" | "minimal";
type ModalTextInputProps = InputHTMLAttributes<HTMLInputElement> & {
variant?: ModalTextInputVariant;
};
const variantClasses: Record<ModalTextInputVariant, string> = {
glass: "h-10 w-full rounded-xl border border-white/70 bg-white px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60",
minimal:
"w-full bg-transparent font-serif text-2xl text-gray-800 outline-none placeholder:text-gray-300 disabled:cursor-not-allowed disabled:text-gray-400",
};
export const ModalTextInput = forwardRef<HTMLInputElement, ModalTextInputProps>(
({ className, variant = "glass", ...props }, ref) => (
<input
ref={ref}
className={cn(variantClasses[variant], className)}
{...props}
/>
),
);
ModalTextInput.displayName = "ModalTextInput";

View file

@ -0,0 +1,22 @@
"use client";
import { forwardRef, type TextareaHTMLAttributes } from "react";
import { cn } from "@/app/lib/utils";
type ModalTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const ModalTextarea = forwardRef<
HTMLTextAreaElement,
ModalTextareaProps
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"min-h-24 w-full resize-none rounded-xl border border-white/70 bg-white px-3 py-2.5 text-sm leading-relaxed text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
{...props}
/>
));
ModalTextarea.displayName = "ModalTextarea";

View file

@ -0,0 +1,435 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { User, Loader2 } from "lucide-react";
import type { ProjectPeople } from "@/app/lib/mikeApi";
import { AddUserInput } from "../shared/AddUserInput";
import { Modal } from "./Modal";
/**
* Any resource the modal can manage members for projects today, tabular
* reviews now, anything else with a `shared_with` email list later.
*/
export interface SharedResource {
id: string;
shared_with?: string[] | null;
owner_display_name?: string | null;
owner_email?: string | null;
}
interface Props {
open: boolean;
onClose: () => void;
/** The thing being shared (project, review, …). */
resource: SharedResource | null;
/**
* Resolve the owner + members roster for the given resource. Different
* resource types hit different endpoints (`/projects/:id/people`,
* `/tabular-review/:id/people`, ) so the caller passes the appropriate
* fetcher.
*/
fetchPeople: (id: string) => Promise<ProjectPeople>;
/** Currently signed-in user's email — gets the "You" tag if it matches. */
currentUserEmail?: string | null;
breadcrumb: string[];
/**
* Persist a new shared_with list. Parent should PATCH the resource and
* sync its local state on success. Throw to surface an error inline.
*/
onSharedWithChange?: (sharedWith: string[]) => Promise<void> | void;
}
type RosterRow = {
email: string | null;
user_id?: string | null;
display_name: string | null;
role: "owner" | "member";
};
/**
* Roster of every Mike member with access to the project, with controls to
* add/remove members. Mirrors AddDocumentsModal's frame.
*/
export function PeopleModal({
open,
onClose,
resource,
fetchPeople,
currentUserEmail,
breadcrumb,
onSharedWithChange,
}: Props) {
const [busy, setBusy] = useState<"add" | "remove" | null>(null);
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
const [memberMenuEmail, setMemberMenuEmail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Server-resolved roster: owner email/display_name + members'
// display_names. We keep `resource.shared_with` as the source of truth
// for membership and just merge display_names from this fetch.
const [people, setPeople] = useState<ProjectPeople | null>(null);
const [lookupDisplayByEmail, setLookupDisplayByEmail] = useState<
Map<string, string | null>
>(new Map());
const [peopleLoading, setPeopleLoading] = useState(false);
const [loadedRosterKey, setLoadedRosterKey] = useState<string | null>(null);
const resourceId = resource?.id ?? null;
const sharedWith: string[] = useMemo(
() =>
Array.isArray(resource?.shared_with)
? (resource.shared_with as string[])
: [],
[resource?.shared_with],
);
useEffect(() => {
if (!open) return;
setError(null);
setBusy(null);
setRemovingEmail(null);
setMemberMenuEmail(null);
}, [open]);
useEffect(() => {
if (!memberMenuEmail) return;
function handleClickAway(event: PointerEvent) {
const target = event.target;
if (
target instanceof HTMLElement &&
target.closest("[data-people-member-menu]")
) {
return;
}
setMemberMenuEmail(null);
}
document.addEventListener("pointerdown", handleClickAway);
return () =>
document.removeEventListener("pointerdown", handleClickAway);
}, [memberMenuEmail]);
// Re-fetch roster whenever the modal opens or membership changes —
// keyed by the joined shared_with list so add/remove triggers a refresh.
const sharedKey = sharedWith
.map((e) => e.toLowerCase())
.sort()
.join(",");
const rosterKey = `${resourceId ?? ""}:${sharedKey}`;
useEffect(() => {
if (!open || !resourceId) return;
let cancelled = false;
setPeopleLoading(true);
setPeople(null);
setLoadedRosterKey(null);
fetchPeople(resourceId)
.then((data) => {
if (cancelled) return;
setPeople(data);
setLoadedRosterKey(rosterKey);
})
.catch(() => {
if (!cancelled) setLoadedRosterKey(rosterKey);
})
.finally(() => {
if (!cancelled) setPeopleLoading(false);
});
return () => {
cancelled = true;
};
}, [open, resourceId, rosterKey, fetchPeople]);
if (!open || !resource) return null;
const memberDisplayByEmail = new Map<string, string | null>();
for (const m of people?.members ?? []) {
memberDisplayByEmail.set(m.email.toLowerCase(), m.display_name);
}
const ownerEmail =
people?.owner.email?.trim().toLowerCase() ??
resource.owner_email?.trim().toLowerCase() ??
null;
const ownerDisplayName =
people?.owner.display_name ?? resource.owner_display_name ?? null;
const roster: RosterRow[] = [];
if (people?.owner || ownerEmail || ownerDisplayName) {
roster.push({
email: ownerEmail,
user_id: people?.owner.user_id ?? null,
display_name: ownerDisplayName,
role: "owner",
});
}
for (const email of sharedWith) {
const lower = email.toLowerCase();
if (ownerEmail && lower === ownerEmail.toLowerCase()) continue;
roster.push({
email,
display_name:
memberDisplayByEmail.get(lower) ??
lookupDisplayByEmail.get(lower) ??
null,
role: "member",
});
}
const normalizedCurrentUserEmail =
currentUserEmail?.trim().toLowerCase() ?? null;
const sharedLower = sharedWith.map((e) => e.toLowerCase());
const rosterPending = peopleLoading || loadedRosterKey !== rosterKey;
function validateNewEmail(email: string) {
if (sharedLower.includes(email)) return `${email} already has access.`;
if (ownerEmail && email === ownerEmail.toLowerCase()) {
return `${email} is the owner.`;
}
if (
normalizedCurrentUserEmail &&
email === normalizedCurrentUserEmail
) {
return "You cannot share this with yourself.";
}
return null;
}
async function handleAddUser(user: {
email: string;
display_name: string | null;
}) {
setLookupDisplayByEmail((prev) => {
const next = new Map(prev);
next.set(user.email.trim().toLowerCase(), user.display_name);
return next;
});
await handleAdd(user.email);
}
async function handleAdd(email: string) {
if (!onSharedWithChange || busy !== null) return;
setBusy("add");
setError(null);
try {
const next = [...sharedWith, email];
await onSharedWithChange(next);
} catch (e) {
throw new Error(
e instanceof Error
? e.message
: "Couldn't add the member. Try again.",
);
} finally {
setBusy(null);
}
}
async function handleRemove(email: string) {
if (!onSharedWithChange || busy !== null) return;
setBusy("remove");
setRemovingEmail(email);
setError(null);
try {
const next = sharedWith.filter(
(e) => e.toLowerCase() !== email.toLowerCase(),
);
await onSharedWithChange(next);
} catch (e) {
setError(
e instanceof Error
? e.message
: "Couldn't remove the member. Try again.",
);
} finally {
setBusy(null);
setRemovingEmail(null);
setMemberMenuEmail(null);
}
}
return (
<Modal open={open} onClose={onClose} breadcrumbs={breadcrumb}>
<div className="flex min-h-0 flex-1 flex-col gap-5 pb-5">
{/* Add-member row */}
{onSharedWithChange && (
<section className="space-y-2">
<AddUserInput
onAdd={handleAddUser}
validateEmail={validateNewEmail}
busy={busy === "add"}
placeholder="Add by email..."
autoFocus
submitLabel="Add member"
className="bg-white focus-within:bg-white"
/>
{error && (
<p className="mt-1.5 text-xs text-red-500">
{error}
</p>
)}
</section>
)}
<section className="flex min-h-0 flex-1 flex-col">
<div className="mb-2 flex items-center gap-2">
<h3 className="text-xs font-medium text-gray-500">
People with Access
</h3>
{peopleLoading && (
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
)}
</div>
{/* Member list */}
{rosterPending ? (
<div className="min-h-0 flex-1 space-y-1">
{[1, 2].map((item) => (
<div
key={item}
className="flex items-center gap-2.5 rounded-lg px-2 py-1.5"
>
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-100" />
<div className="min-w-0 flex-1">
<div className="h-3 w-40 animate-pulse rounded bg-gray-100" />
</div>
<div className="h-4 w-12 shrink-0 animate-pulse rounded-full bg-gray-100" />
</div>
))}
</div>
) : roster.length === 0 ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-gray-400">
No one has access yet.
</div>
) : (
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{roster.map((entry) => {
const entryEmail = entry.email ?? "";
const rowKey =
entry.email ??
entry.user_id ??
`${entry.role}-unknown`;
const isYou =
!!currentUserEmail &&
!!entryEmail &&
entryEmail.toLowerCase() ===
currentUserEmail.toLowerCase();
const isRemoving =
busy === "remove" &&
removingEmail === entryEmail;
const displayName = entry.display_name?.trim();
const primary = isYou
? "You"
: displayName || entryEmail || "User";
const showEmail =
!isYou && !!displayName && !!entryEmail;
const initial = displayName
?.charAt(0)
.toUpperCase();
return (
<li
key={`${entry.role}-${rowKey}`}
className="group relative flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-100/70"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/80 bg-white text-gray-700 shadow-[0_4px_12px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.64)]">
{initial ? (
<span className="font-serif text-[11px] leading-none">
{initial}
</span>
) : (
<User className="h-2.5 w-2.5" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-gray-800">
{primary}
{showEmail && (
<span className="text-gray-400">
{" "}
· {entry.email}
</span>
)}
</p>
</div>
{entry.role === "owner" && (
<span className="shrink-0 rounded-full px-2 py-1 text-xs text-gray-400">
Owner
</span>
)}
{entry.role === "member" && (
<div
className="relative flex shrink-0 items-center"
data-people-member-menu
>
<span className="rounded-full px-2 py-1 text-xs text-gray-400">
Member
</span>
{onSharedWithChange && (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setMemberMenuEmail(
(current) =>
current ===
entryEmail
? null
: entryEmail,
);
}}
disabled={
busy !== null
}
title="Member actions"
className={`flex h-6 items-center justify-center overflow-hidden rounded-full text-xs leading-none text-gray-500 transition-all hover:bg-gray-200/70 hover:text-gray-800 disabled:opacity-50 ${
memberMenuEmail ===
entryEmail
? "w-6 opacity-100"
: "w-0 opacity-0 group-hover:w-6 group-hover:opacity-100"
}`}
>
···
</button>
{memberMenuEmail ===
entryEmail && (
<div
className="absolute right-0 top-full z-30 mt-1 min-w-28 overflow-hidden rounded-xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_8px_20px_rgba(15,23,42,0.09),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl"
onClick={(
event,
) =>
event.stopPropagation()
}
>
<button
type="button"
onClick={() =>
void handleRemove(
entryEmail,
)
}
disabled={
busy !==
null
}
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
{isRemoving && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Delete
</button>
</div>
)}
</>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</section>
</div>
</Modal>
);
}

View file

@ -0,0 +1,136 @@
"use client";
import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Folder } from "lucide-react";
import { SearchBar } from "@/app/components/ui/search-bar";
import type { Project } from "../shared/types";
import { Modal } from "./Modal";
type PrimaryAction = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
label: ReactNode;
};
interface Props {
open: boolean;
onClose: () => void;
projects: Project[];
loading: boolean;
selectedId: string | null;
onSelect: (id: string | null) => void;
breadcrumbs?: ReactNode[];
primaryAction?: PrimaryAction;
}
export function ProjectPickerModal({
open,
onClose,
projects,
loading,
selectedId,
onSelect,
breadcrumbs,
primaryAction,
}: Props) {
const [search, setSearch] = useState("");
const q = search.toLowerCase().trim();
const filtered = q
? projects.filter((p) => p.name.toLowerCase().includes(q))
: projects;
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={breadcrumbs}
primaryAction={primaryAction}
>
<div className="pt-1 pb-2">
<SearchBar
value={search}
onValueChange={setSearch}
placeholder="Search projects..."
autoFocus
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
{loading ? (
<div className="space-y-px">
<div className="flex items-center rounded-md px-2 py-2">
<div className="h-3 w-14 rounded bg-gray-100 animate-pulse" />
</div>
{[65, 45, 80, 55, 70].map((w, i) => (
<div
key={i}
className="flex items-center gap-2 rounded-md px-2 py-2"
>
<div className="h-3.5 w-3.5 rounded-full border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-100 animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
</div>
) : filtered.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">
{q ? "No matches found" : "No projects yet"}
</p>
) : (
<div className="rounded-sm overflow-hidden">
<div className="flex items-center justify-between px-2 py-2">
<p className="text-xs font-medium text-gray-400">
Projects
</p>
</div>
<div className="space-y-px">
{filtered.map((project) => {
const isSelected = selectedId === project.id;
const documentCount =
project.document_count ??
project.documents?.length ??
0;
return (
<button
key={project.id}
onClick={() =>
onSelect(
isSelected ? null : project.id,
)
}
className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-100/70"}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`}
>
{isSelected && (
<span className="h-1.5 w-1.5 rounded-sm bg-white" />
)}
</span>
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span
className={`flex-1 truncate ${isSelected ? "text-gray-900" : "text-gray-700"}`}
>
{project.name}
{project.cm_number && (
<span className="ml-1 font-normal text-gray-400">
(#{project.cm_number})
</span>
)}
</span>
<span className="shrink-0 text-gray-400">
{documentCount}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
</Modal>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { useRouter } from "next/navigation";
import { AlertTriangle } from "lucide-react";
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
import { WarningPopup } from "../popups/WarningPopup";
interface Props {
open: boolean;
onClose: () => void;
provider: ModelProvider | null;
/** Optional override for the body sentence. */
message?: string;
}
export function ApiKeyMissingPopup({ open, onClose, provider, message }: Props) {
const router = useRouter();
if (!open) return null;
const providerName = provider ? providerLabel(provider) : "this provider";
const body =
message ??
`You haven't added a ${providerName} API key yet. Add one in your account settings to use this model.`;
const handleGoToAccount = () => {
onClose();
router.push("/account/models");
};
return (
<WarningPopup
open={open}
onClose={onClose}
title="API key required"
message={body}
icon={
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
}
primaryAction={{
label: "Go to account settings",
onClick: handleGoToAccount,
}}
/>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { PillButton } from "@/app/components/ui/pill-button";
import { cn } from "@/app/lib/utils";
type ConfirmStatus = "idle" | "loading" | "complete";
interface ConfirmPopupProps {
open: boolean;
title?: ReactNode;
message?: ReactNode;
confirmLabel?: ReactNode;
confirmStatus?: ConfirmStatus;
cancelLabel?: ReactNode;
onConfirm: () => void;
onCancel: () => void;
confirmDisabled?: boolean;
className?: string;
}
export function ConfirmPopup({
open,
title,
message,
confirmLabel = "Confirm",
confirmStatus = "idle",
cancelLabel = "Cancel",
onConfirm,
onCancel,
confirmDisabled = false,
className,
}: ConfirmPopupProps) {
if (!open) return null;
const confirmBusy = confirmStatus === "loading";
const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle";
const normalizedConfirmLabel =
typeof confirmLabel === "string" ? confirmLabel : "Confirm";
const isDeleteAction = normalizedConfirmLabel.toLowerCase() === "delete";
const resolvedConfirmLabel =
confirmStatus === "loading" ? (
<span className="inline-flex h-full items-center gap-1.5">
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
{progressiveLabel(normalizedConfirmLabel)}
</span>
) : confirmStatus === "complete" ? (
completedLabel(normalizedConfirmLabel)
) : isDeleteAction ? (
<span className="inline-flex h-full items-center gap-1.5">
<Trash2 className="h-3 w-3 shrink-0" />
{confirmLabel}
</span>
) : (
confirmLabel
);
return createPortal(
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4">
<div
className={cn(
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white px-4 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.92)] backdrop-blur-2xl",
className,
)}
>
{title && (
<div className="text-sm font-medium text-gray-950 mb-3">
{title}
</div>
)}
{message && (
<div
className={cn("text-xs text-gray-700", title && "mt-1")}
>
{message}
</div>
)}
<div className="mt-3 flex items-center justify-end gap-2">
<PillButton
tone="white"
size="sm"
onClick={onCancel}
>
{cancelLabel}
</PillButton>
<PillButton
tone={isDeleteAction ? "danger" : "black"}
size="sm"
onClick={onConfirm}
disabled={resolvedConfirmDisabled}
className="h-7 px-3.5 leading-none"
aria-busy={confirmBusy}
>
{resolvedConfirmLabel}
</PillButton>
</div>
</div>
</div>,
document.body,
);
}
function progressiveLabel(label: string) {
const lower = label.toLowerCase();
if (lower.endsWith("e")) return `${label.slice(0, -1)}ing...`;
return `${label}ing...`;
}
function completedLabel(label: string) {
const lower = label.toLowerCase();
if (lower.endsWith("e")) return `${label}d`;
return `${label}ed`;
}

View file

@ -0,0 +1,294 @@
"use client";
import {
useEffect,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
} from "react";
import { Loader2 } from "lucide-react";
import { supabase } from "@/app/lib/supabase";
import { Modal } from "../modals/Modal";
type MfaFactor = {
id: string;
friendly_name?: string | null;
factor_type: string;
};
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
export async function needsMfaVerification() {
const { data, error } =
await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (error) throw error;
return data.nextLevel === "aal2" && data.currentLevel !== "aal2";
}
interface MfaVerificationPopupProps {
open: boolean;
onCancel: () => void;
onVerified: () => void;
title?: string;
message?: string;
}
export function MfaVerificationPopup({
open,
onCancel,
onVerified,
title = "Two-factor verification required",
message = "Enter a code from your authenticator app to continue.",
}: MfaVerificationPopupProps) {
const [factors, setFactors] = useState<MfaFactor[]>([]);
const [selectedFactorId, setSelectedFactorId] = useState("");
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const canVerify =
!verifying &&
!loading &&
!!selectedFactorId &&
code.trim().length === 6;
useEffect(() => {
if (!open) return;
let cancelled = false;
devLog("[mfa-popup] opened");
async function loadFactors() {
setLoading(true);
setError(null);
setCode("");
const { data, error: listError } =
await supabase.auth.mfa.listFactors();
if (cancelled) return;
if (listError) {
devLog("[mfa-popup] list factors failed", {
error: listError.message,
});
setError(listError.message);
setFactors([]);
setSelectedFactorId("");
} else {
const verified = (data.totp ?? []) as MfaFactor[];
devLog("[mfa-popup] factors loaded", {
totpCount: verified.length,
selectedFactorId: verified[0]?.id ?? null,
});
setFactors(verified);
setSelectedFactorId(verified[0]?.id ?? "");
}
setLoading(false);
}
void loadFactors();
return () => {
cancelled = true;
};
}, [open]);
async function verify() {
if (!canVerify) return;
setVerifying(true);
setError(null);
devLog("[mfa-popup] verifying code", { factorId: selectedFactorId });
const { error: verifyError } =
await supabase.auth.mfa.challengeAndVerify({
factorId: selectedFactorId,
code: code.trim(),
});
setVerifying(false);
if (verifyError) {
devLog("[mfa-popup] verification failed", {
error: verifyError.message,
});
setError(verifyError.message);
return;
}
devLog("[mfa-popup] verification succeeded");
setCode("");
onVerified();
}
if (!open) return null;
return (
<Modal
open={open}
onClose={onCancel}
breadcrumbs={[title]}
size="sm"
className="h-auto min-h-[310px] max-h-[min(92vh,400px)]"
cancelAction={{
label: "Cancel",
onClick: onCancel,
disabled: verifying,
}}
primaryAction={{
label: verifying ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Verifying...
</span>
) : (
"Verify"
),
onClick: () => void verify(),
disabled: !canVerify,
}}
>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto pb-2 pt-0">
<p className="text-sm text-gray-500 pb-6">{message}</p>
{loading ? (
<div className="flex h-13 items-center justify-center text-sm text-gray-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading authenticator...
</div>
) : factors.length === 0 ? (
<p className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
No verified authenticator factor is available for this
session.
</p>
) : (
<div className="space-y-4">
{factors.length > 1 && (
<select
value={selectedFactorId}
onChange={(event) =>
setSelectedFactorId(event.target.value)
}
className="h-9 w-full rounded-lg bg-gray-100 px-3 text-sm text-gray-900 outline-none focus-visible:ring-2 focus-visible:ring-gray-300/45"
>
{factors.map((factor) => (
<option key={factor.id} value={factor.id}>
{factor.friendly_name ||
"Authenticator app"}
</option>
))}
</select>
)}
<VerificationCodeInput
value={code}
onChange={setCode}
disabled={verifying}
autoFocus={open && !loading}
onSubmit={() => void verify()}
canSubmit={canVerify}
/>
</div>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
</Modal>
);
}
export function VerificationCodeInput({
value,
onChange,
disabled,
autoFocus,
onSubmit,
canSubmit,
}: {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
autoFocus?: boolean;
onSubmit?: () => void;
canSubmit?: boolean;
}) {
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length: 6 }, (_, index) => value[index] ?? "");
useEffect(() => {
if (!autoFocus || disabled) return;
const focusTimer = window.setTimeout(() => {
const firstEmptyIndex = digits.findIndex((digit) => !digit);
inputsRef.current[
firstEmptyIndex === -1 ? 0 : firstEmptyIndex
]?.focus();
}, 0);
return () => window.clearTimeout(focusTimer);
}, [autoFocus, disabled]);
function updateDigit(index: number, nextValue: string) {
const digit = nextValue.replace(/\D/g, "").slice(-1);
const nextDigits = [...digits];
nextDigits[index] = digit;
onChange(nextDigits.join(""));
if (digit && index < inputsRef.current.length - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function handlePaste(event: ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const pasted = event.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, 6);
if (!pasted) return;
onChange(pasted);
inputsRef.current[Math.min(pasted.length, 6) - 1]?.focus();
}
function handleKeyDown(
event: KeyboardEvent<HTMLInputElement>,
index: number,
) {
if (event.key === "Enter") {
event.preventDefault();
if (canSubmit) onSubmit?.();
return;
}
if (event.key === "Backspace" && !digits[index] && index > 0) {
inputsRef.current[index - 1]?.focus();
}
if (event.key === "ArrowLeft" && index > 0) {
event.preventDefault();
inputsRef.current[index - 1]?.focus();
}
if (event.key === "ArrowRight" && index < digits.length - 1) {
event.preventDefault();
inputsRef.current[index + 1]?.focus();
}
}
return (
<div
className="flex justify-center gap-2"
role="group"
aria-label="Six digit verification code"
>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => {
inputsRef.current[index] = element;
}}
type="text"
inputMode="numeric"
autoComplete={index === 0 ? "one-time-code" : "off"}
value={digit}
disabled={disabled}
onChange={(event) => updateDigit(index, event.target.value)}
onPaste={handlePaste}
onKeyDown={(event) => handleKeyDown(event, index)}
className="h-13 w-12 rounded-lg border border-gray-300 bg-gray-50 text-center text-2xl font-medium font-serif text-gray-950 shadow-none outline-none transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-300/45 disabled:cursor-not-allowed disabled:opacity-45"
aria-label={`Verification code digit ${index + 1}`}
maxLength={1}
/>
))}
</div>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { Lock } from "lucide-react";
import { WarningPopup } from "../popups/WarningPopup";
interface Props {
open: boolean;
onClose: () => void;
/** Short headline above the body, e.g. "Owner-only action". */
title?: string;
/** Sentence describing what the user tried to do. */
action?: string;
/** Email of the project/resource owner, shown so the user knows who to ask. */
ownerEmail?: string | null;
/** Override the default message entirely. */
message?: string;
}
/**
* Lightweight "you don't have permission" popup shown when a non-owner
* attempts an owner-only action (manage people, rename, delete, ) on a
* shared project. Replaces the silent 404 the backend would otherwise
* return so the user understands why the action didn't go through.
*/
export function OwnerOnlyPopup({
open,
onClose,
title = "Owner-only action",
action,
ownerEmail,
message,
}: Props) {
if (!open) return null;
const body =
message ??
(action
? `Only the project owner can ${action}.`
: "Only the project owner can perform this action.");
return (
<WarningPopup
open={open}
onClose={onClose}
title={title}
message={body}
icon={<Lock className="h-3.5 w-3.5 shrink-0 text-red-600" />}
>
{ownerEmail && (
<p className="mt-1 text-xs text-gray-600">
Ask <span className="text-gray-600">{ownerEmail}</span> if
you need access.
</p>
)}
</WarningPopup>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { AlertCircle, X } from "lucide-react";
import { cn } from "@/app/lib/utils";
interface WarningPopupAction {
label: ReactNode;
onClick: () => void;
disabled?: boolean;
}
interface WarningPopupProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
message?: ReactNode;
children?: ReactNode;
icon?: ReactNode;
primaryAction?: WarningPopupAction;
secondaryAction?: WarningPopupAction;
className?: string;
}
export function WarningPopup({
open,
onClose,
title,
message,
children,
icon,
primaryAction,
secondaryAction,
className,
}: WarningPopupProps) {
if (!open) return null;
const warningIcon = icon ?? (
<AlertCircle className="h-3 w-3 shrink-0 text-red-600" />
);
return createPortal(
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
<div
className={cn(
"pointer-events-auto flex items-start gap-2 rounded-2xl border border-white/70 bg-red-50/75 px-3 py-2 text-xs shadow-[0_4px_12px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-6px_12px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
className,
)}
>
<div className="min-w-0 flex-1 self-center text-red-600">
{title && (
<div className="flex items-center gap-1.5 font-medium mb-1">
{warningIcon}
{title}
</div>
)}
{message && (
<div
className={cn(!title && "flex items-start gap-1.5")}
>
{!title && warningIcon}
<span className="min-w-0">{message}</span>
</div>
)}
{children}
{(primaryAction || secondaryAction) && (
<div className="mt-2 flex items-center gap-2">
{secondaryAction && (
<WarningPopupButton action={secondaryAction} />
)}
{primaryAction && (
<WarningPopupButton
action={primaryAction}
primary
/>
)}
</div>
)}
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 text-red-700 transition-colors hover:text-red-500"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>,
document.body,
);
}
function WarningPopupButton({
action,
primary = false,
}: {
action: WarningPopupAction;
primary?: boolean;
}) {
return (
<button
type="button"
onClick={action.onClick}
disabled={action.disabled}
className={cn(
"rounded-lg px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
primary
? "bg-gray-900 text-white hover:bg-gray-700"
: "text-gray-700 hover:bg-white/70",
)}
>
{action.label}
</button>
);
}

View file

@ -19,7 +19,7 @@ import { WarningPopup } from "@/app/components/popups/WarningPopup";
import type { Document } from "@/app/components/shared/types";
import { isSpreadsheetFilename } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { formatBytes } from "./ProjectPageParts";
const MIN_DOC_COLUMN_WIDTH = 420;
@ -489,7 +489,6 @@ export function DocumentSidePanel({
documentId={doc.id}
versionId={selectedVersionId}
rounded={false}
bordered={false}
/>
) : (
<PdfView
@ -499,7 +498,6 @@ export function DocumentSidePanel({
version_id: selectedVersionId,
}}
rounded={false}
bordered={false}
/>
)}
</div>

View file

@ -12,7 +12,7 @@ import { FileDirectory } from "../shared/FileDirectory";
import { AddUserInput } from "../shared/AddUserInput";
import type { Project } from "../shared/types";
import type { UserLookupResult } from "@/app/lib/mikeApi";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import { ModalTextInput } from "../modals/ModalTextInput";
@ -151,12 +151,12 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
breadcrumbs={[
"Projects",
"New project",
step === "details" ? "Details" : "Documents",
step === "details" ? "Details" : "Add Documents",
]}
secondaryAction={
step === "documents"
? {
label: `Upload documents${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
label: `Upload${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
icon: <Upload className="h-3.5 w-3.5" />,
onClick: () => fileInputRef.current?.click(),
disabled: loading,
@ -309,7 +309,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
</div>
</div>
) : (
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={standaloneDocuments}
directoryProjects={dirProjects}
@ -319,6 +319,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
emptyMessage="No existing documents"
searchable
searchAutoFocus
showProjectTabs
/>
</div>
)}

View file

@ -44,7 +44,7 @@ import {
AddDocumentsModal,
invalidateDirectoryCache,
} from "@/app/components/modals/AddDocumentsModal";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { WarningPopup } from "@/app/components/popups/WarningPopup";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import {

View file

@ -0,0 +1,73 @@
"use client";
import { useMemo } from "react";
import { PRACTICE_OPTIONS } from "../workflows/practices";
import { ModalSelect, type ModalSelectOption } from "../modals/ModalSelect";
import { ModalTextInput } from "../modals/ModalTextInput";
const OPTION_NONE = "__none__";
const OPTION_OTHER = "Other";
interface ProjectPracticeFieldProps {
id: string;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export function ProjectPracticeField({
id,
value,
onChange,
disabled = false,
}: ProjectPracticeFieldProps) {
const selectedOption = useMemo(() => {
if (!value.trim()) return OPTION_NONE;
return (PRACTICE_OPTIONS as readonly string[]).includes(value)
? value
: OPTION_OTHER;
}, [value]);
const customValue =
selectedOption === OPTION_OTHER && value !== OPTION_OTHER ? value : "";
const options = useMemo<ModalSelectOption[]>(
() => [
{ value: OPTION_NONE, label: "None" },
...PRACTICE_OPTIONS,
],
[],
);
function handleSelect(option: string) {
if (option === OPTION_NONE) {
onChange("");
return;
}
if (option === OPTION_OTHER) {
onChange(OPTION_OTHER);
return;
}
onChange(option);
}
return (
<div className="space-y-2">
<ModalSelect
id={id}
value={selectedOption}
options={options}
onChange={handleSelect}
placeholder="Select practice"
disabled={disabled}
/>
{selectedOption === OPTION_OTHER && (
<ModalTextInput
type="text"
value={customValue}
onChange={(event) => onChange(event.target.value)}
placeholder="Enter practice..."
disabled={disabled}
/>
)}
</div>
);
}

View file

@ -34,8 +34,8 @@ import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { PeopleModal } from "@/app/components/modals/PeopleModal";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { ProjectDetailsModal } from "./ProjectDetailsModal";
import {
ProjectPageHeader,

View file

@ -9,7 +9,7 @@ import {
deleteProject,
} from "@/app/lib/mikeApi";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import type { Project } from "@/app/components/shared/types";
import { NewProjectModal } from "./NewProjectModal";
import { ProjectDetailsModal } from "./ProjectDetailsModal";

View file

@ -1,8 +1,8 @@
"use client";
import { Suspense } from "react";
import { AuthProvider } from "@/contexts/AuthContext";
import { UserProfileProvider } from "@/contexts/UserProfileContext";
import { AuthProvider } from "@/app/contexts/AuthContext";
import { UserProfileProvider } from "@/app/contexts/UserProfileContext";
import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate";
export function Providers({ children }: { children: React.ReactNode }) {

View file

@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import type { KeyboardEvent } from "react";
import { Loader2, UserPlus } from "lucide-react";
import {
lookupUserByEmail,
type UserLookupResult,
} from "@/app/lib/mikeApi";
import { cn } from "@/app/lib/utils";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface AddUserInputProps {
onAdd: (user: UserLookupResult) => Promise<void> | void;
validateEmail?: (email: string) => Promise<string | null> | string | null;
busy?: boolean;
placeholder?: string;
autoFocus?: boolean;
submitLabel?: string;
className?: string;
}
export function AddUserInput({
onAdd,
validateEmail,
busy = false,
placeholder = "Add by email...",
autoFocus = false,
submitLabel = "Add user",
className,
}: AddUserInputProps) {
const [input, setInput] = useState("");
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const trimmedEmail = input.trim().toLowerCase();
const showAddButton = trimmedEmail.length > 0;
async function commitUser() {
const email = trimmedEmail;
if (!email || busy || checking) return;
if (!EMAIL_RE.test(email)) {
setError("Enter a valid email.");
return;
}
setError(null);
setChecking(true);
try {
const validationError = await validateEmail?.(email);
if (validationError) {
setError(validationError);
return;
}
const user = await lookupUserByEmail(email);
if (!user.exists) {
setError(`${email} does not belong to a Mike user.`);
return;
}
await onAdd(user);
setInput("");
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not add this user. Try again.",
);
} finally {
setChecking(false);
}
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
void commitUser();
}
}
return (
<div>
<div
className={cn(
"flex min-h-10 items-center gap-2 rounded-xl border border-white/70 bg-white/55 px-3 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors focus-within:bg-white/70",
className,
)}
>
<UserPlus className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<input
type="email"
value={input}
onChange={(event) => {
setInput(event.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-w-0 flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400"
autoFocus={autoFocus}
/>
{showAddButton && (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void commitUser()}
disabled={busy || checking}
title={submitLabel}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-blue-500/35 bg-blue-600/90 px-2.5 text-[11px] font-medium leading-none text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{(busy || checking) && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Add
</button>
)}
</div>
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
import {
PanelLeft,
MessageSquare,
Folder,
FolderOpen,
Table2,
Library,
@ -11,16 +12,16 @@ import {
ChevronsUpDown,
ChevronDown,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
import { listProjects } from "@/app/lib/mikeApi";
import type { Project } from "@/app/components/shared/types";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
const NAV_ITEMS = [
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
@ -85,9 +86,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
});
}, [user]);
useEffect(() => {
if (!isOpen) setShouldAnimate(true);
}, [isOpen]);
const handleToggle = () => {
if (isOpen) setShouldAnimate(true);
onToggle();
};
useEffect(() => {
const handleClickOutside = () => setIsDropdownOpen(false);
@ -121,113 +123,222 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
if (!user) return null;
return (
<div
className={cn(
isOpen
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65"
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto",
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
<>
{/* Mobile: tapping outside the expanded sidebar closes it. The
sidebar (z-[99]) sits above this scrim (z-[98]); md+ is
unaffected since the sidebar is part of the layout there. */}
{isOpen && (
<div
className="fixed inset-0 z-[98] bg-gray-300/20 md:hidden"
onClick={handleToggle}
aria-hidden="true"
/>
)}
>
{/* Toggle + Logo */}
<div
className={`items-center justify-between px-2.5 py-3 ${
!isOpen ? "hidden md:flex" : "flex"
}`}
className={cn(
isOpen
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65"
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto",
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
)}
>
{/* Toggle + Logo */}
<div
className={`items-center justify-between px-2.5 py-3 ${
!isOpen ? "hidden md:flex" : "flex"
}`}
>
{isOpen && (
<div className="px-2">
<Link
href="/assistant"
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
>
<MikeIcon size={22} />
<span
className={`text-2xl font-light font-serif ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
Mike
</span>
</Link>
</div>
)}
<button
onClick={handleToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-md hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>
<PanelLeft className="h-4 w-4" />
</button>
</div>
{/* Nav items */}
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const isActive =
href === "/assistant"
? pathname === href
: href === "/projects"
? pathname === href
: pathname === href ||
pathname.startsWith(href + "/");
return (
<div key={href} className="py-0.5 px-2.5">
<button
onClick={() => router.push(href)}
title={!isOpen ? label : ""}
className={cn(
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
isActive
? "bg-gray-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
!isOpen ? "hidden md:flex" : "flex",
)}
>
<Icon
className={`h-4 w-4 flex-shrink-0 ${
isActive
? "text-gray-900"
: "text-black"
}`}
/>
{isOpen && (
<span
className={`text-sm font-medium ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{label}
</span>
)}
</button>
</div>
);
})}
{isOpen && (
<div className="px-2">
<Link
href="/assistant"
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
>
<MikeIcon size={22} />
<span
className={`text-2xl font-light font-serif ${
<div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
{/* Recent Projects */}
<div>
<button
onClick={() => setProjectsCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
Mike
</span>
</Link>
</div>
)}
<button
onClick={onToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-md hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>
<PanelLeft className="h-4 w-4" />
</button>
</div>
{/* Nav items */}
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const isActive =
href === "/assistant"
? pathname === href
: href === "/projects"
? pathname === href
: pathname === href ||
pathname.startsWith(href + "/");
return (
<div key={href} className="py-0.5 px-2.5">
<button
onClick={() => router.push(href)}
title={!isOpen ? label : ""}
className={cn(
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
isActive
? "bg-gray-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
!isOpen ? "hidden md:flex" : "flex",
)}
>
<Icon
className={`h-4 w-4 flex-shrink-0 ${
isActive ? "text-gray-900" : "text-black"
}`}
/>
{isOpen && (
<span
className={`text-sm font-medium ${
shouldAnimate ? "sidebar-fade-in-2" : ""
<span>Recent Projects</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
projectsCollapsed ? "-rotate-90" : ""
}`}
>
{label}
</span>
/>
</button>
{!projectsCollapsed && (
<>
{!recentProjects ? (
<div className="space-y-1 px-2.5">
{[50, 65, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
>
<div
className="h-3 bg-gray-200 rounded animate-pulse"
style={{
width: `${w}%`,
}}
/>
</div>
))}
</div>
) : recentProjects.length === 0 ? (
<div
className={`px-5 py-2 text-xs text-gray-500 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
No projects yet
</div>
) : (
<div
className={`space-y-1 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{recentProjects.map((project) => {
const isActive =
pathname ===
`/projects/${project.id}` ||
pathname.startsWith(
`/projects/${project.id}/`,
);
return (
<button
key={project.id}
onClick={() =>
router.push(
`/projects/${project.id}`,
)
}
title={project.name}
className={cn(
"flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors",
isActive
? "bg-gray-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
)}
>
{isActive ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-600" />
)}
<span className="min-w-0 flex-1 truncate">
{project.name}
</span>
</button>
);
})}
</div>
)}
</>
)}
</button>
</div>
);
})}
</div>
{isOpen && (
<div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
{/* Recent Projects */}
<div>
<button
onClick={() => setProjectsCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
<span>Recent Projects</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
projectsCollapsed ? "-rotate-90" : ""
{/* Assistant History */}
<div className="flex min-h-0 flex-1 flex-col">
<button
onClick={() => setHistoryCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
/>
</button>
{!projectsCollapsed && (
<>
{!recentProjects ? (
>
<span>Assistant History</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
historyCollapsed ? "-rotate-90" : ""
}`}
/>
</button>
<div
className={`overflow-y-auto flex-1 ${
historyCollapsed ? "hidden" : ""
}`}
>
{!chats ? (
<div className="space-y-1 px-2.5">
{[50, 65, 45].map((w, i) => (
{[40, 60, 50, 70, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
@ -239,220 +350,142 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
</div>
))}
</div>
) : recentProjects.length === 0 ? (
) : chats.length === 0 ? (
<div
className={`px-5 py-2 text-xs text-gray-500 ${
className={`text-xs text-gray-500 py-2 px-5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
No projects yet
No chats yet
</div>
) : (
<div
className={`space-y-1 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{recentProjects.map((project) => {
const isActive =
pathname ===
`/projects/${project.id}` ||
pathname.startsWith(
`/projects/${project.id}/`,
);
return (
<button
key={project.id}
onClick={() =>
router.push(
`/projects/${project.id}`,
)
<>
<div
className={`space-y-1 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{chats.map((chat) => (
<SidebarChatItem
key={chat.id}
chat={chat}
isActive={
routeChatId === chat.id
}
title={project.name}
projectName={
chat.project_id
? projectNames[
chat
.project_id
]
: undefined
}
onSelect={() => {
setCurrentChatId(
chat.id,
);
router.push(
chat.project_id
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
: `/assistant/chat/${chat.id}`,
);
}}
/>
))}
</div>
{hasMoreChats && (
<div className="px-2.5 pt-1">
<button
onClick={loadMoreChats}
className={cn(
"flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors",
isActive
? "bg-gray-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
"hover:bg-gray-100",
)}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
<span className="min-w-0 flex-1 truncate">
{project.name}
</span>
Load more
</button>
);
})}
</div>
</div>
)}
</>
)}
</>
)}
</div>
</div>
</div>
)}
{/* Assistant History */}
<div className="flex min-h-0 flex-1 flex-col">
<button
onClick={() => setHistoryCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
<span>Assistant History</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
historyCollapsed ? "-rotate-90" : ""
}`}
/>
</button>
<div
className={`overflow-y-auto flex-1 ${
historyCollapsed ? "hidden" : ""
}`}
>
{!chats ? (
<div className="space-y-1 px-2.5">
{[40, 60, 50, 70, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
>
<div
className="h-3 bg-gray-200 rounded animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
{/* User Profile */}
<div className="mt-auto p-1">
{user && (
<div className="relative">
<button
onClick={() =>
setIsDropdownOpen(!isDropdownOpen)
}
className={cn(
"flex items-center transition-colors w-full px-2.5 py-3 border-t",
"rounded-xl border-white/60",
!isOpen ? "hidden md:flex" : "",
pathname === "/account" || isDropdownOpen
? "bg-gray-200/60"
: "hover:bg-gray-100",
)}
title={!isOpen ? user.email : undefined}
>
<div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
{getUserInitials(user.email)}
</div>
) : chats.length === 0 ? (
<div
className={`text-xs text-gray-500 py-2 px-5 ${
shouldAnimate ? "sidebar-fade-in-2" : ""
}`}
>
No chats yet
</div>
) : (
<>
{isOpen && (
<div
className={`space-y-1 px-2.5 ${
className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{chats.map((chat) => (
<SidebarChatItem
key={chat.id}
chat={chat}
isActive={
routeChatId === chat.id
}
projectName={
chat.project_id
? projectNames[
chat.project_id
]
: undefined
}
onSelect={() => {
setCurrentChatId(chat.id);
router.push(
chat.project_id
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
: `/assistant/chat/${chat.id}`,
);
}}
/>
))}
</div>
{hasMoreChats && (
<div className="px-2.5 pt-1">
<button
onClick={loadMoreChats}
className={cn(
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
"hover:bg-gray-100",
)}
>
Load more
</button>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="text-sm font-medium text-gray-900 leading-none">
{getDisplayName()}
</div>
<div className="text-[12px] text-gray-500 leading-none">
{getUserTier()}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" />
</div>
)}
</button>
{/* User Profile */}
<div className="mt-auto p-1">
{user && (
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className={cn(
"flex items-center transition-colors w-full px-2.5 py-3 border-t",
"rounded-xl border-white/60",
!isOpen ? "hidden md:flex" : "",
pathname === "/account" || isDropdownOpen
? "bg-gray-200/60"
: "hover:bg-gray-100",
)}
title={!isOpen ? user.email : undefined}
>
<div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
{getUserInitials(user.email)}
</div>
{isOpen && (
{isDropdownOpen && (
<div
className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${
shouldAnimate ? "sidebar-fade-in-2" : ""
}`}
className={cn(
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
isOpen ? "right-0" : "w-56",
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
)}
>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="text-sm font-medium text-gray-900 leading-none">
{getDisplayName()}
</div>
<div className="text-[12px] text-gray-500 leading-none">
{getUserTier()}
</div>
</div>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" />
<button
onClick={() => {
router.push("/account");
setIsDropdownOpen(false);
}}
className={cn(
"w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md",
"hover:bg-white/70",
)}
>
<User className="h-4 w-4" />
Account Settings
</button>
</div>
)}
</button>
{isDropdownOpen && (
<div
className={cn(
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
isOpen ? "right-0" : "w-56",
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
)}
>
<button
onClick={() => {
router.push("/account");
setIsDropdownOpen(false);
}}
className={cn(
"w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md",
"hover:bg-white/70",
)}
>
<User className="h-4 w-4" />
Account Settings
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
</>
);
}

View file

@ -7,13 +7,13 @@ import {
ChevronRight,
Folder,
FolderOpen,
Trash2,
Loader2,
} from "lucide-react";
import type { Document, Project } from "./types";
import { VersionChip } from "./VersionChip";
import { FileTypeIcon } from "./FileTypeIcon";
import { SearchBar } from "@/components/ui/search-bar";
import { SearchBar } from "@/app/components/ui/search-bar";
import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle";
function formatDate(iso: string | null) {
if (!iso) return null;
@ -37,13 +37,12 @@ interface FileDirectoryProps {
allowMultiple?: boolean;
forceExpanded?: boolean;
emptyMessage?: string;
heading?: string;
onDelete?: (ids: string[]) => void | Promise<void>;
uploadingFilenames?: string[];
searchable?: boolean;
searchPlaceholder?: string;
searchAutoFocus?: boolean;
searchNoResultsMessage?: string;
showProjectTabs?: boolean;
}
export function FileDirectory({
@ -55,21 +54,21 @@ export function FileDirectory({
allowMultiple = true,
forceExpanded = false,
emptyMessage = "No documents yet",
heading = "Documents",
onDelete,
uploadingFilenames = [],
searchable = false,
searchPlaceholder = "Search...",
searchAutoFocus = false,
searchNoResultsMessage = "No matches found",
showProjectTabs,
}: FileDirectoryProps) {
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
new Set(),
);
const [deleting, setDeleting] = useState(false);
const [selectedTab, setSelectedTab] = useState<"files" | "projects">(
"files",
);
const [search, setSearch] = useState("");
const selectedCount = selectedIds.size;
const q = search.trim().toLowerCase();
const visibleStandaloneDocs = q
? standaloneDocs.filter((doc) => doc.filename.toLowerCase().includes(q))
@ -104,30 +103,22 @@ export function FileDirectory({
);
})
: directoryProjects;
async function handleDelete() {
if (!onDelete || selectedCount === 0 || deleting) return;
const ids = Array.from(selectedIds);
setDeleting(true);
try {
await onDelete(ids);
const next = new Set(selectedIds);
ids.forEach((id) => next.delete(id));
onChange(next);
} finally {
setDeleting(false);
}
}
const showTabs = showProjectTabs ?? directoryProjects.length > 0;
const activeTab = showTabs ? selectedTab : "files";
const hasVisibleFiles =
visibleStandaloneDocs.length > 0 ||
visibleUploadingFilenames.length > 0;
const hasVisibleProjects = visibleDirectoryProjects.length > 0;
const activeTabHasNoResults =
q &&
((activeTab === "files" && !hasVisibleFiles) ||
(activeTab === "projects" && !hasVisibleProjects));
const allDocs = [
...standaloneDocs,
...directoryProjects.flatMap((p) => p.documents ?? []),
];
const allStandaloneSelected =
visibleStandaloneDocs.length > 0 &&
visibleStandaloneDocs.every((d) => selectedIds.has(d.id));
function toggle(docId: string) {
if (!allowMultiple) {
onChange(new Set([docId]));
@ -142,18 +133,6 @@ export function FileDirectory({
onChange(next);
}
function toggleAll() {
if (allStandaloneSelected) {
const next = new Set(selectedIds);
visibleStandaloneDocs.forEach((d) => next.delete(d.id));
onChange(next);
} else {
const next = new Set(selectedIds);
visibleStandaloneDocs.forEach((d) => next.add(d.id));
onChange(next);
}
}
function toggleFolder(projectId: string) {
if (forceExpanded) return;
setExpandedProjects((prev) => {
@ -169,23 +148,23 @@ export function FileDirectory({
if (loading) {
return (
<div className="space-y-px">
<div className="flex min-h-0 flex-1 flex-col space-y-2">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName="mb-2"
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
{/* Documents header skeleton */}
<div className="flex items-center justify-between rounded-md px-2 py-2">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
</div>
{/* File rows skeleton */}
<div>
{showTabs && (
<FileDirectoryTabs
activeTab={activeTab}
onChange={setSelectedTab}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{[60, 45, 75, 55, 40].map((w, i) => (
<div
key={i}
@ -210,255 +189,269 @@ export function FileDirectory({
uploadingFilenames.length === 0
) {
return (
<div>
<div className="flex min-h-0 flex-1 flex-col space-y-2">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName="mb-2"
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
</p>
{showTabs && (
<FileDirectoryTabs
activeTab={activeTab}
onChange={setSelectedTab}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
</p>
</div>
</div>
);
}
return (
<div className="space-y-2 rounded-sm">
<div className="flex min-h-0 flex-1 flex-col space-y-2 rounded-sm">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
{q &&
visibleStandaloneDocs.length === 0 &&
visibleDirectoryProjects.length === 0 &&
visibleUploadingFilenames.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">
{searchNoResultsMessage}
</p>
{showTabs && (
<FileDirectoryTabs
activeTab={activeTab}
onChange={setSelectedTab}
/>
)}
{activeTabHasNoResults ? (
<div className="min-h-0 flex-1 overflow-y-auto">
<p className="text-center text-sm text-gray-400 py-8">
{searchNoResultsMessage}
</p>
</div>
) : (
<div>
{(visibleStandaloneDocs.length > 0 ||
visibleUploadingFilenames.length > 0 ||
(onDelete && selectedCount > 0)) && (
<div className="flex items-center justify-between px-2 py-2">
<p className="text-xs font-medium text-gray-400">
{heading}
</p>
<div className="flex items-center gap-3">
{onDelete && selectedCount > 0 && (
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
)}
{visibleStandaloneDocs.length > 0 && (
<button
type="button"
onClick={toggleAll}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
{allStandaloneSelected
? "Deselect all"
: "Select all"}
</button>
)}
</div>
</div>
)}
{visibleUploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
>
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
<span className="flex-1 truncate text-gray-400">
{filename}
</span>
<span className="shrink-0 text-gray-300">
Uploading
</span>
</div>
))}
{visibleStandaloneDocs.map((doc) => {
const selected = selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() => toggle(doc.id)}
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
<div className="min-h-0 flex-1 overflow-y-auto">
{activeTab === "files" && (
<>
{visibleUploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`flex-1 truncate ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(doc.created_at)}
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
<span className="flex-1 truncate text-gray-400">
{filename}
</span>
)}
</button>
);
})}
{visibleStandaloneDocs.length > 0 &&
visibleDirectoryProjects.length > 0 && (
<div className="py-2 px-2 mt-2">
<p className="text-xs font-medium text-gray-400">
Projects
</p>
</div>
)}
{visibleDirectoryProjects.map((project) => {
const isExpanded =
forceExpanded ||
!!q ||
expandedProjects.has(project.id);
const docs = project.documents ?? [];
return (
<div key={project.id}>
<button
type="button"
onClick={() => toggleFolder(project.id)}
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
<span className="flex-1 truncate font-medium text-gray-700">
{project.name}
{project.cm_number && (
<span className="ml-1 font-normal text-gray-400">
(#{project.cm_number})
<span className="shrink-0 text-gray-300">
Uploading
</span>
</div>
))}
{visibleStandaloneDocs.map((doc) => {
const selected = selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() => toggle(doc.id)}
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`flex-1 truncate ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(doc.created_at)}
</span>
)}
</span>
<span className="text-xs text-gray-400 shrink-0">
{docs.length}
</span>
</button>
{isExpanded && (
<div>
{docs.length === 0 ? (
<p className="pl-7 py-1 text-xs text-gray-400">
Empty
</p>
</button>
);
})}
{!q &&
visibleStandaloneDocs.length === 0 &&
visibleUploadingFilenames.length === 0 && (
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
</p>
)}
</>
)}
{activeTab === "projects" &&
visibleDirectoryProjects.map((project) => {
const isExpanded =
forceExpanded ||
!!q ||
expandedProjects.has(project.id);
const docs = project.documents ?? [];
return (
<div key={project.id}>
<button
type="button"
onClick={() =>
toggleFolder(project.id)
}
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
) : (
docs.map((doc) => {
const selected =
selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() =>
toggle(doc.id)
}
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
<span className="flex-1 truncate font-medium text-gray-700">
{project.name}
{project.cm_number && (
<span className="ml-1 font-normal text-gray-400">
(#{project.cm_number})
</span>
)}
</span>
<span className="text-xs text-gray-400 shrink-0">
{docs.length}
</span>
</button>
{isExpanded && (
<div>
{docs.length === 0 ? (
<p className="pl-7 py-1 text-xs text-gray-400">
Empty
</p>
) : (
docs.map((doc) => {
const selected =
selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() =>
toggle(doc.id)
}
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon
fileType={
doc.file_type
}
/>
<span
className={`flex-1 truncate min-w-0 ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(
doc.created_at,
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
);
})}
<DocFileIcon
fileType={
doc.file_type
}
/>
<span
className={`flex-1 truncate min-w-0 ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(
doc.created_at,
)}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
);
})}
{activeTab === "projects" &&
!q &&
visibleDirectoryProjects.length === 0 && (
<p className="text-center text-sm text-gray-400 py-8">
No projects yet
</p>
)}
</div>
)}
</div>
);
}
function FileDirectoryTabs({
activeTab,
onChange,
}: {
activeTab: "files" | "projects";
onChange: (tab: "files" | "projects") => void;
}) {
return (
<ModalSegmentedToggle
value={activeTab}
onChange={onChange}
options={[
{ value: "files", label: "Files" },
{ value: "projects", label: "Projects" },
]}
size="sm"
className="self-start"
/>
);
}

View file

@ -6,8 +6,8 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
} from "@/app/components/ui/dropdown-menu";
import { cn } from "@/app/lib/utils";
export type HeaderActionsMenuItem = {
label: string;

View file

@ -2,8 +2,8 @@
import { useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { needsMfaVerification } from "../popups/MfaVerificationPopup";
type GateState = "idle" | "checking" | "required" | "verified";

View file

@ -12,7 +12,7 @@ import {
import { createPortal } from "react-dom";
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { usePageChrome } from "@/app/contexts/PageChromeContext";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export interface PageHeaderBreadcrumb {
label?: ReactNode;

View file

@ -7,12 +7,12 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/components/ui/dropdown-menu";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import type { Chat } from "@/app/components/shared/types";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
interface Props {
chat: Chat;

View file

@ -8,7 +8,7 @@ import {
type MouseEvent,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import {
CLOSE_ROW_ACTIONS_EVENT,
closeRowActionMenus,

View file

@ -590,16 +590,20 @@ export interface WorkflowContributor {
export interface Workflow {
id: string;
user_id: string | null;
title: string;
type: "assistant" | "tabular";
prompt_md: string | null;
metadata: {
title: string;
description: string | null;
type: "assistant" | "tabular";
contributors: WorkflowContributor[];
language: string;
version: string | null;
practice: string | null;
jurisdictions: string[] | null;
};
skill_md: string | null;
columns_config: ColumnConfig[] | null;
is_system: boolean;
created_at: string;
language?: string | null;
version?: string | null;
practice?: string | null;
jurisdictions?: string[] | null;
shared_by_name?: string | null;
allow_edit?: boolean;
is_owner?: boolean;

View file

@ -0,0 +1,503 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { Loader2 } from "lucide-react";
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
import { supabase } from "@/app/lib/supabase";
import {
clearDocxQuoteHighlights,
highlightDocxQuote,
} from "./highlightDocxQuote";
import type { CitationQuote } from "../types";
interface Props {
documentId: string;
versionId?: string | null;
/**
* Called once the document has been rendered to the DOM. Handy for
* scrolling to a particular tracked change after a re-render.
*/
onReady?: () => void;
/**
* Tracked-change to scroll to + briefly flash after each render. The
* `key` is used to re-trigger scrolling when the same edit is clicked
* twice in a row.
*/
highlightEdit?: {
key: string;
inserted_text?: string;
deleted_text?: string;
/**
* Numeric w:id values of the <w:ins>/<w:del> wrappers in
* document.xml. Preferred over text matching uniquely identifies
* the right DOM element even when multiple edits share identical
* inserted/deleted text. `docx-preview` drops these during parsing,
* so we re-tag each rendered <ins>/<del> with data-w-id after load.
*/
ins_w_id?: string | null;
del_w_id?: string | null;
} | null;
/**
* Forces a byte re-fetch when it changes, even if documentId/versionId
* are stable. Used after accept/reject: the backend overwrites bytes at
* the same storage path (no new version row), so the hook has no other
* signal that the file changed.
*/
refetchKey?: number;
/**
* Citation quotes to highlight in the rendered output. The first match
* is scrolled into view. Page numbers are ignored DOCX has no explicit
* pagination the renderer can match against.
*/
quotes?: CitationQuote[];
/** Changes when the parent wants the current quote re-focused. */
quoteFocusKey?: string | number;
/**
* Warning banner copy rendered in the top-left of the viewer. Used
* for non-blocking errors (e.g. "Accept failed — reverted").
*/
warning?: string | null;
/**
* Called when the user dismisses the warning banner.
*/
onWarningDismiss?: () => void;
/**
* Scroll position to restore after the first render used by parents
* that track per-tab scroll and want to re-enter at the same spot.
* Null/undefined means "no override" (preserve the pre-render scroll).
*/
initialScrollTop?: number | null;
/**
* Fires on scroll (throttled by rAF) so the parent can persist the
* current scrollTop against its tab state.
*/
onScrollChange?: (scrollTop: number) => void;
rounded?: boolean;
}
function findEditElement(
root: HTMLElement,
tag: "ins" | "del",
opts: { w_id?: string | null; text?: string },
): HTMLElement | null {
if (opts.w_id) {
const byId = root.querySelector(
`${tag}[data-w-id="${CSS.escape(opts.w_id)}"]`,
) as HTMLElement | null;
if (byId) return byId;
}
const text = opts.text ?? "";
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
const target = normalize(text);
if (!target) return null;
const candidates = Array.from(root.querySelectorAll(tag)) as HTMLElement[];
return (
candidates.find((el) => normalize(el.textContent ?? "") === target) ??
candidates.find((el) =>
normalize(el.textContent ?? "").includes(target),
) ??
null
);
}
function scrollToHighlight(
container: HTMLElement,
scrollEl: HTMLElement,
edit: {
inserted_text?: string;
deleted_text?: string;
ins_w_id?: string | null;
del_w_id?: string | null;
},
) {
const insEl = findEditElement(container, "ins", {
w_id: edit.ins_w_id,
text: edit.inserted_text,
});
const delEl = findEditElement(container, "del", {
w_id: edit.del_w_id,
text: edit.deleted_text,
});
const anchor = insEl ?? delEl;
if (!anchor) return;
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = anchor.getBoundingClientRect();
const offset = targetRect.top - scrollRect.top + scrollEl.scrollTop - 80;
scrollEl.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
const flashed = [insEl, delEl].filter((el): el is HTMLElement => !!el);
flashed.forEach((el) => el.classList.add("docx-edit-flash"));
window.setTimeout(() => {
flashed.forEach((el) => el.classList.remove("docx-edit-flash"));
}, 2000);
}
/**
* Fetch the ordered list of w:ids for every w:ins/w:del in the current
* version and tag each rendered <ins>/<del> with data-w-id. The backend
* returns ids in document order, and docx-preview emits <ins>/<del>
* in the same order, so we can align by index.
*/
async function tagWIdsOnRenderedDom(
container: HTMLElement,
documentId: string,
versionId: string | null | undefined,
): Promise<void> {
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
const qs = versionId
? `?version_id=${encodeURIComponent(versionId)}`
: "";
const resp = await fetch(
`${apiBase}/single-documents/${documentId}/tracked-change-ids${qs}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
);
if (!resp.ok) {
console.warn(
"[DocxView] tracked-change-ids fetch failed",
resp.status,
);
return;
}
const data = (await resp.json()) as {
ids: { kind: "ins" | "del"; w_id: string }[];
};
const domEls = Array.from(
container.querySelectorAll("ins, del"),
) as HTMLElement[];
const ids = data.ids ?? [];
let tagged = 0;
let mismatched = 0;
for (let i = 0; i < Math.min(domEls.length, ids.length); i++) {
const el = domEls[i];
const info = ids[i];
if (el.tagName.toLowerCase() !== info.kind) {
mismatched++;
continue;
}
el.setAttribute("data-w-id", info.w_id);
tagged++;
}
} catch (e) {
console.warn("[DocxView] tagWIdsOnRenderedDom failed", e);
}
}
/**
* Renders a .docx in the browser using `docx-preview`. Tracked changes
* (`w:ins` / `w:del`) show up automatically with coloured strike/underline
* styling. Scroll position is preserved across re-renders so Accept/Reject
* doesn't jump the user back to the top.
*/
export function DocxView({
documentId,
versionId,
onReady,
highlightEdit,
refetchKey,
quotes,
quoteFocusKey,
warning,
onWarningDismiss,
initialScrollTop,
onScrollChange,
rounded = true,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastScrollTopRef = useRef(0);
const renderKeyRef = useRef(0);
// Ref-stabilize onReady and highlightEdit so the render effect only
// re-fires when `bytes` actually change. Without this, any parent
// re-render (e.g. clicking a new highlight) creates a new onReady
// identity, triggers a full re-render, and snaps scroll back to top.
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
const highlightEditRef = useRef(highlightEdit);
highlightEditRef.current = highlightEdit;
const quotesRef = useRef(quotes);
quotesRef.current = quotes;
const initialScrollTopRef = useRef(initialScrollTop ?? null);
initialScrollTopRef.current = initialScrollTop ?? null;
const onScrollChangeRef = useRef(onScrollChange);
onScrollChangeRef.current = onScrollChange;
// Stable key for the quote list so the re-highlight effect re-fires
// only when the actual text/order of quotes changes.
const quoteKey = useMemo(
() => (quotes ?? []).map((q) => q.quote).join("||"),
[quotes],
);
const { bytes, loading, error } = useFetchDocxBytes(
documentId,
versionId,
refetchKey,
);
/**
* Highlight every quote in `list` inside the rendered DOM and scroll
* the first match into view. Returns true if any match was found.
*/
const applyQuoteHighlights = (
containerEl: HTMLElement,
scrollEl: HTMLElement,
list: CitationQuote[] | undefined,
): boolean => {
clearDocxQuoteHighlights(containerEl);
if (!list || list.length === 0) return false;
let firstMatch: HTMLElement | null = null;
for (const q of list) {
const match = highlightDocxQuote(containerEl, q.quote);
if (match && !firstMatch) firstMatch = match;
}
if (!firstMatch) return false;
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = firstMatch.getBoundingClientRect();
const offset =
targetRect.top -
scrollRect.top +
scrollEl.scrollTop -
scrollEl.clientHeight / 2 +
targetRect.height / 2;
scrollEl.scrollTo({
top: Math.max(0, offset),
behavior: "instant" as ScrollBehavior,
});
return true;
};
/**
* docx-preview renders pages at their natural Word page width (e.g.
* ~816px for US Letter). When the side-panel is narrower than that,
* the page overflows horizontally. Apply CSS `zoom` on each
* section.docx so the document shrinks to fit `zoom` (unlike
* `transform: scale`) also shrinks the layout box, so the scroll
* container's scrollHeight adapts. We zoom each page rather than the
* wrapper because docx-preview injects flex styles on `.docx-wrapper`
* that can interfere with wrapper-level zoom.
*/
const applyDocxScale = () => {
const containerEl = containerRef.current;
const scrollEl = scrollRef.current;
if (!containerEl || !scrollEl) return;
const wrapper = containerEl.querySelector<HTMLElement>(".docx-wrapper");
if (!wrapper) return;
const sections = Array.from(
wrapper.querySelectorAll<HTMLElement>("section.docx"),
);
if (sections.length === 0) return;
// Reset zoom on every page before measuring so offsetWidth reports
// each page's natural width (pages can have different widths — e.g.
// mixed portrait/landscape sections).
sections.forEach((s) => {
s.style.zoom = "1";
});
// Use the scroll container's content box (clientWidth - padding)
// as the available width.
const styles = window.getComputedStyle(scrollEl);
const padX =
(parseFloat(styles.paddingLeft) || 0) +
(parseFloat(styles.paddingRight) || 0);
const available = scrollEl.clientWidth - padX;
if (available <= 0) return;
// Scale each page independently against its own natural width so
// landscape/custom-size pages still fit without distorting the
// page dividers.
sections.forEach((s) => {
const w = s.offsetWidth;
if (!w) return;
const scale = Math.min(1, available / w);
s.style.zoom = String(scale);
});
};
// Observe the scroll container (which tracks the side panel's width)
// and re-scale whenever it resizes. Also observe the docx container so
// we re-scale once docx-preview finishes inserting pages.
useEffect(() => {
const scrollEl = scrollRef.current;
const containerEl = containerRef.current;
if (!scrollEl || !containerEl) return;
let raf = 0;
const schedule = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => applyDocxScale());
};
const ro = new ResizeObserver(schedule);
ro.observe(scrollEl);
ro.observe(containerEl);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, []);
useEffect(() => {
let cancelled = false;
if (!bytes || !containerRef.current || !scrollRef.current) return;
const scrollEl = scrollRef.current;
const containerEl = containerRef.current;
// Remember scroll position across re-renders so Accept/Reject stays put.
lastScrollTopRef.current = scrollEl.scrollTop;
const thisRender = ++renderKeyRef.current;
(async () => {
try {
const { renderAsync } = await import("docx-preview");
if (cancelled) return;
containerEl.innerHTML = "";
await renderAsync(bytes, containerEl, undefined, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
renderChanges: true,
experimental: true,
});
if (cancelled) return;
await tagWIdsOnRenderedDom(
containerEl,
documentId,
versionId ?? null,
);
if (cancelled) return;
// Scale to fit before scrolling so offsets are computed
// against the post-zoom layout.
applyDocxScale();
requestAnimationFrame(() => {
if (
!scrollRef.current ||
thisRender !== renderKeyRef.current
)
return;
const pendingHighlight = highlightEditRef.current;
const pendingQuotes = quotesRef.current;
const pendingInitialScroll = initialScrollTopRef.current;
if (pendingHighlight) {
scrollToHighlight(
containerEl,
scrollRef.current,
pendingHighlight,
);
// Highlight quotes too, but don't override the edit scroll
if (pendingQuotes?.length) {
for (const q of pendingQuotes)
highlightDocxQuote(containerEl, q.quote);
}
} else if (
pendingQuotes &&
applyQuoteHighlights(
containerEl,
scrollRef.current,
pendingQuotes,
)
) {
// scrolled inside applyQuoteHighlights
} else if (typeof pendingInitialScroll === "number") {
scrollRef.current.scrollTop = pendingInitialScroll;
} else {
scrollRef.current.scrollTop = lastScrollTopRef.current;
}
onReadyRef.current?.();
});
} catch (e) {
console.error("docx-preview render failed", e);
}
})();
return () => {
cancelled = true;
};
}, [bytes]);
// Re-scroll/highlight if the target edit changes without a re-render
// (e.g. same doc, different edit clicked).
useEffect(() => {
if (!highlightEdit || !containerRef.current || !scrollRef.current)
return;
scrollToHighlight(
containerRef.current,
scrollRef.current,
highlightEdit,
);
}, [highlightEdit?.key]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-apply quote highlights when the quote list changes without a full
// re-render (e.g. clicking a different citation on the same doc).
useEffect(() => {
if (!containerRef.current || !scrollRef.current) return;
applyQuoteHighlights(
containerRef.current,
scrollRef.current,
quotesRef.current,
);
}, [quoteKey, quoteFocusKey]); // eslint-disable-line react-hooks/exhaustive-deps
// Fire onScrollChange (rAF-throttled) so parents can persist scroll
// per-tab. We still maintain lastScrollTopRef locally for same-mount
// re-renders (Accept/Reject preserving scroll within one view).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
let scheduled = false;
const onScroll = () => {
lastScrollTopRef.current = el.scrollTop;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
onScrollChangeRef.current?.(el.scrollTop);
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
return (
<div
className={`relative flex flex-col flex-1 overflow-hidden bg-gray-100 ${rounded ? "rounded-lg" : ""}`}
>
{warning && (
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800 shadow-sm">
<span>{warning}</span>
<button
type="button"
onClick={() => onWarningDismiss?.()}
className="text-amber-600 hover:text-amber-900"
aria-label="Dismiss warning"
>
×
</button>
</div>
)}
<div
ref={scrollRef}
className="flex-1 overflow-auto px-5 pt-5 pb-3 docx-view-scroll"
data-document-id={documentId}
data-version-id={versionId ?? ""}
>
{loading && !bytes && (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
)}
{error && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div ref={containerRef} className="docx-view-container" />
</div>
</div>
);
}

View file

@ -0,0 +1,598 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
import { DocxView } from "./DocxView";
import type { CitationQuote } from "../types";
import {
clearHighlights,
getPdfJs,
highlightQuote,
STANDARD_FONT_DATA_URL,
} from "./highlightQuote";
interface Props {
doc: { document_id: string; version_id?: string | null } | null;
/** Preferred: one or more (page, quote) pairs to highlight. */
quotes?: CitationQuote[];
/** Changes when the parent wants the current quote re-focused. */
quoteFocusKey?: string | number;
/** Back-compat single-quote API. Ignored if `quotes` is provided. */
quote?: string;
fallbackPage?: number;
rounded?: boolean;
}
type QuoteEntry = { page?: number; quote: string };
const SIDE_PADDING = 20;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3.0;
const ZOOM_STEP = 0.25;
type RenderedPage = {
page: import("pdfjs-dist").PDFPageProxy;
viewport: import("pdfjs-dist").PageViewport;
wrapper: HTMLDivElement;
canvas: HTMLCanvasElement;
textDivs: HTMLElement[];
};
export function PdfView({
doc,
quotes,
quoteFocusKey,
quote,
fallbackPage,
rounded = true,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const pdfDocRef = useRef<import("pdfjs-dist").PDFDocumentProxy | null>(
null,
);
const renderedPagesRef = useRef<RenderedPage[]>([]);
const quoteListRef = useRef<QuoteEntry[]>([]);
const zoomRef = useRef(1.0);
const currentPageRef = useRef(1);
const quoteList: QuoteEntry[] = useMemo(() => {
if (quotes?.length)
return quotes.map((q) => ({ page: q.page, quote: q.quote }));
if (quote) return [{ page: fallbackPage, quote }];
return [];
}, [quotes, quote, fallbackPage]);
// Stable string key so effects can depend on quote-list identity
const quoteKey = quoteList
.map((q) => `${q.page ?? ""}:${q.quote}`)
.join("|");
const [containerWidth, setContainerWidth] = useState(0);
const [zoom, setZoom] = useState(1.0);
const [currentPage, setCurrentPage] = useState(1);
const [numPages, setNumPages] = useState(0);
const { result, loading, error } = useFetchSingleDoc(
doc?.document_id ?? null,
doc?.version_id ?? null,
);
// /display returned DOCX bytes — the active version has no PDF
// rendition, so fall back to docx-preview (still applies citation
// highlighting via the same `quotes` API).
const fallbackToDocx = result?.type === "docx";
// Track container width via ResizeObserver so re-renders fire on resize
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0]?.contentRect.width ?? 0);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Track current page via scroll position
useEffect(() => {
const scrollEl = scrollContainerRef.current;
if (!scrollEl) return;
const handleScroll = () => {
const pages = renderedPagesRef.current;
if (!pages.length) return;
const scrollCenter = scrollEl.scrollTop + scrollEl.clientHeight / 2;
let closest = 0;
let closestDist = Infinity;
pages.forEach((p, i) => {
const pageCenter =
p.wrapper.offsetTop + p.wrapper.clientHeight / 2;
const dist = Math.abs(pageCenter - scrollCenter);
if (dist < closestDist) {
closestDist = dist;
closest = i;
}
});
currentPageRef.current = closest + 1;
setCurrentPage(closest + 1);
};
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
return () => scrollEl.removeEventListener("scroll", handleScroll);
}, []);
// Highlights all entries in `list` across the already-rendered pages.
// Returns the 1-based page number of the first successfully highlighted entry, or null.
const applyHighlights = useCallback(
async (list: QuoteEntry[]): Promise<number | null> => {
// Clear any prior highlights across all pages
for (const p of renderedPagesRef.current)
clearHighlights(p.textDivs);
let firstHitPage: number | null = null;
for (const entry of list) {
let hitPage: number | null = null;
if (entry.page) {
const target = renderedPagesRef.current[entry.page - 1];
if (target) {
const found = await highlightQuote(
target.textDivs,
entry.quote,
);
if (found) hitPage = entry.page;
}
}
// Fall back to scanning all pages for this quote
if (hitPage === null) {
console.warn(
`Quote not found on hinted page, scanning all pages: "${entry.quote.slice(0, 60)}..."`,
);
for (let i = 0; i < renderedPagesRef.current.length; i++) {
const p = renderedPagesRef.current[i];
const found = await highlightQuote(
p.textDivs,
entry.quote,
);
if (found) {
hitPage = i + 1;
break;
}
}
}
if (hitPage !== null && firstHitPage === null) {
firstHitPage = hitPage;
}
}
return firstHitPage;
},
[],
);
const renderPDF = useCallback(
async (
doc: import("pdfjs-dist").PDFDocumentProxy,
list: QuoteEntry[],
scrollToPage?: number,
) => {
if (!containerRef.current) return;
containerRef.current.innerHTML = "";
renderedPagesRef.current = [];
const lib = await getPdfJs();
lib.TextLayer.cleanup();
setNumPages(doc.numPages);
setCurrentPage(1);
currentPageRef.current = 1;
const hasCitation = list.length > 0;
if (hasCitation && scrollContainerRef.current) {
scrollContainerRef.current.style.opacity = "0";
}
const reveal = () => {
if (scrollContainerRef.current)
scrollContainerRef.current.style.opacity = "1";
};
const panelW = containerRef.current.clientWidth;
const firstPage = await doc.getPage(1);
const naturalWidth = firstPage.getViewport({ scale: 1 }).width;
const baseScale = Math.max(
0.5,
(panelW - SIDE_PADDING) / naturalWidth,
);
const scale = baseScale * zoomRef.current;
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale });
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.margin = "0 auto 8px";
wrapper.style.width = "fit-content";
wrapper.className = "shadow-md";
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.display = "block";
wrapper.appendChild(canvas);
containerRef.current?.appendChild(wrapper);
const ctx = canvas.getContext("2d");
if (!ctx) continue;
const task = page.render({ canvasContext: ctx, viewport });
try {
await task.promise;
} catch (e: unknown) {
if (
(e as { name?: string })?.name !==
"RenderingCancelledException"
) {
console.error("PDF render error", e);
}
continue;
}
const textLayerDiv = document.createElement("div");
textLayerDiv.className = "pdf-text-layer";
textLayerDiv.style.position = "absolute";
textLayerDiv.style.left = "0";
textLayerDiv.style.top = "0";
textLayerDiv.style.width = `${viewport.width}px`;
textLayerDiv.style.height = `${viewport.height}px`;
textLayerDiv.style.setProperty("--scale-factor", String(scale));
wrapper.appendChild(textLayerDiv);
const textLayer = new lib.TextLayer({
textContentSource: page.streamTextContent(),
container: textLayerDiv,
viewport,
});
await textLayer.render();
const textDivs = textLayer.textDivs;
renderedPagesRef.current.push({
page,
viewport,
wrapper,
canvas,
textDivs,
});
}
// Apply highlights across all entries, then scroll to the first hit.
let targetPage: number | null = null;
if (list.length) {
targetPage = await applyHighlights(list);
if (targetPage === null) {
// Fallback: scroll to the first entry's page hint, even without a highlight
const hint = list.find((e) => e.page)?.page ?? null;
targetPage = hint;
}
}
if (targetPage && targetPage >= 1) {
scrollToHighlightOnPage(targetPage);
} else if (!hasCitation && scrollToPage && scrollToPage > 1) {
// Restore scroll position after zoom re-render
const pageEntry = renderedPagesRef.current[scrollToPage - 1];
if (pageEntry)
pageEntry.wrapper.scrollIntoView({
behavior: "instant" as ScrollBehavior,
block: "start",
});
}
reveal();
},
[applyHighlights],
);
// Scroll so the first highlight on `pageNum` lands at the vertical center
// of the viewer. We compute the scroll position explicitly on the scroll
// container — calling `scrollIntoView` on a child of the absolutely-
// positioned text layer can scroll just the overlay while leaving the
// canvas untouched, which is why we don't use it here.
function scrollToHighlightOnPage(pageNum: number) {
const pageEntry = renderedPagesRef.current[pageNum - 1];
const scrollEl = scrollContainerRef.current;
if (!pageEntry || !scrollEl) return;
const highlightEl = pageEntry.wrapper.querySelector<HTMLElement>(
".pdf-text-highlight",
);
if (highlightEl) {
const containerRect = scrollEl.getBoundingClientRect();
const highlightRect = highlightEl.getBoundingClientRect();
const offsetWithinContainer = highlightRect.top - containerRect.top;
const targetTop =
scrollEl.scrollTop +
offsetWithinContainer -
scrollEl.clientHeight / 2 +
highlightRect.height / 2;
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
} else {
const wrapperRect = pageEntry.wrapper.getBoundingClientRect();
const containerRect = scrollEl.getBoundingClientRect();
const targetTop =
scrollEl.scrollTop + (wrapperRect.top - containerRect.top);
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
}
}
const rehighlightQuotes = useCallback(
async (list: QuoteEntry[]) => {
const targetPage = await applyHighlights(list);
const scrollPage =
targetPage ?? list.find((e) => e.page)?.page ?? null;
if (scrollPage && scrollPage >= 1) {
scrollToHighlightOnPage(scrollPage);
}
},
[applyHighlights],
);
// Trackpad pinch-to-zoom (wheel + ctrlKey)
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaMode === 0 ? e.deltaY / 300 : e.deltaY * 0.1;
const next = Math.min(
ZOOM_MAX,
Math.max(
ZOOM_MIN,
Math.round(zoomRef.current * Math.exp(-delta) * 100) / 100,
),
);
if (next === zoomRef.current) return;
zoomRef.current = next;
setZoom(next);
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}, 150);
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => {
el.removeEventListener("wheel", handleWheel);
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [renderPDF]);
// Touch pinch-to-zoom
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
let initialDist = 0;
let initialZoom = 1.0;
function getTouchDist(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
}
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
initialDist = getTouchDist(e.touches);
initialZoom = zoomRef.current;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || initialDist === 0) return;
e.preventDefault();
const next = Math.min(
ZOOM_MAX,
Math.max(
ZOOM_MIN,
Math.round(
initialZoom *
(getTouchDist(e.touches) / initialDist) *
100,
) / 100,
),
);
zoomRef.current = next;
setZoom(next);
};
const handleTouchEnd = (e: TouchEvent) => {
if (e.touches.length < 2 && initialDist > 0) {
initialDist = 0;
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}
};
el.addEventListener("touchstart", handleTouchStart, { passive: true });
el.addEventListener("touchmove", handleTouchMove, { passive: false });
el.addEventListener("touchend", handleTouchEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", handleTouchStart);
el.removeEventListener("touchmove", handleTouchMove);
el.removeEventListener("touchend", handleTouchEnd);
};
}, [renderPDF]);
// Clean up PDF.js static font-measurement canvases on unmount
useEffect(() => {
return () => {
getPdfJs().then((lib) => lib.TextLayer.cleanup());
};
}, []);
// Render PDF when fetch result arrives
useEffect(() => {
if (!result || result.type !== "pdf") return;
pdfDocRef.current = null;
renderedPagesRef.current = [];
quoteListRef.current = quoteList;
zoomRef.current = 1.0;
setZoom(1.0);
setNumPages(0);
const list = quoteList;
let cancelled = false;
(async () => {
const lib = await getPdfJs();
if (cancelled) return;
const pdfDoc = await lib.getDocument({
data: new Uint8Array(result.buffer),
standardFontDataUrl: STANDARD_FONT_DATA_URL,
}).promise;
if (cancelled) return;
pdfDocRef.current = pdfDoc;
await renderPDF(pdfDoc, list);
})();
return () => {
cancelled = true;
};
}, [result, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-render at new scale when container is resized (debounced 150ms)
useEffect(() => {
if (!pdfDocRef.current) return;
const timer = setTimeout(() => {
if (pdfDocRef.current) {
renderPDF(pdfDocRef.current, quoteListRef.current);
}
}, 150);
return () => clearTimeout(timer);
}, [containerWidth, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-highlight when quotes change without full re-render
useEffect(() => {
if (!pdfDocRef.current) return;
quoteListRef.current = quoteList;
rehighlightQuotes(quoteList);
}, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
function handleZoomIn() {
const next = Math.min(
ZOOM_MAX,
Math.round((zoomRef.current + ZOOM_STEP) * 100) / 100,
);
zoomRef.current = next;
setZoom(next);
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}
function handleZoomOut() {
const next = Math.max(
ZOOM_MIN,
Math.round((zoomRef.current - ZOOM_STEP) * 100) / 100,
);
zoomRef.current = next;
setZoom(next);
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}
if (fallbackToDocx && doc?.document_id) {
return (
<DocxView
documentId={doc.document_id}
versionId={doc.version_id ?? null}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
rounded={rounded}
/>
);
}
return (
<div
className={`relative flex flex-col bg-gray-100 flex-1 overflow-hidden ${rounded ? "rounded-lg" : ""}`}
>
<div
ref={scrollContainerRef}
className="flex-1 overflow-auto px-3 pt-5 pb-3"
>
{loading && (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
)}
{error && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div ref={containerRef} />
</div>
{numPages > 0 && (
<>
{/* Page counter — bottom left */}
<div className="absolute bottom-4 left-4 pointer-events-none">
<span className="flex items-center px-3 py-1.5 rounded-full text-xs font-medium tabular-nums text-gray-700 bg-white/25 backdrop-blur-md border border-white/30 shadow-md">
{currentPage}/{numPages}
</span>
</div>
{/* Zoom controls — bottom right */}
<div className="absolute bottom-4 right-4 flex items-center gap-px rounded-full bg-white/25 backdrop-blur-md border border-white/30 shadow-md px-1 py-1">
<button
onClick={handleZoomOut}
disabled={zoom <= ZOOM_MIN}
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<span className="text-xs font-medium text-gray-600 tabular-nums w-9 text-center select-none">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
disabled={zoom >= ZOOM_MAX}
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,518 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import LuckyExcel, { type LuckyExcelSheet } from "luckyexcel";
import type { WorkbookInstance } from "@fortune-sheet/react";
import type { Cell, Sheet } from "@fortune-sheet/core";
import "@fortune-sheet/react/dist/index.css";
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
type HighlightRange = { row: [number, number]; column: [number, number] };
type WorkbookComponent = typeof import("@fortune-sheet/react").Workbook;
/** A cell locator to highlight, e.g. { sheet: "Q3 Budget", cell: "B7" }. */
export type HighlightCell = { sheet?: string; cell?: string };
interface Props {
documentId: string;
versionId?: string | null;
/** Cell(s) to select/scroll to (from a spreadsheet citation). */
highlightCells?: HighlightCell[];
rounded?: boolean;
}
/** "B" -> 1, "AA" -> 26 (0-based column index). */
function columnLettersToIndex(letters: string): number {
let n = 0;
for (const ch of letters.toUpperCase()) {
n = n * 26 + (ch.charCodeAt(0) - 64);
}
return n - 1;
}
/** Parse an A1 address like "B7" into 0-based { r, c }. */
function parseA1(cell: string): { r: number; c: number } | null {
const m = cell.trim().match(/^([A-Za-z]+)(\d+)$/);
if (!m) return null;
const c = columnLettersToIndex(m[1]);
const r = Number.parseInt(m[2], 10) - 1;
if (c < 0 || r < 0) return null;
return { r, c };
}
/** Parse an A1 address or range ("B7" or "B7:C9") into 0-based row/col spans. */
function parseRange(range: string): HighlightRange | null {
const [startRaw, endRaw] = range.split(":");
const start = parseA1(startRaw);
if (!start) return null;
const end = endRaw ? parseA1(endRaw) : start;
if (!end) return null;
return {
row: [Math.min(start.r, end.r), Math.max(start.r, end.r)],
column: [Math.min(start.c, end.c), Math.max(start.c, end.c)],
};
}
/**
* Expand a highlight range to cover any merged ranges it intersects. Fortune-
* sheet paints a merge as one block anchored at its top-left cell and never
* paints the covered cells, so a citation to a covered cell (e.g. B1 inside
* A1:C1) would otherwise highlight nothing. The model is asked to cite the full
* merged range, but this is a deterministic fallback for when it cites a covered
* cell anyway. Expanding to the anchor makes `afterRenderCell` paint the block.
*/
function expandRangeForMerges(
sheet: Sheet,
range: HighlightRange,
): HighlightRange {
const merges = (sheet.config as { merge?: Record<string, MergeInfo> })
?.merge;
if (!merges) return range;
let [r0, r1] = range.row;
let [c0, c1] = range.column;
for (const m of Object.values(merges)) {
const mr1 = m.r + m.rs - 1;
const mc1 = m.c + m.cs - 1;
if (r0 <= mr1 && r1 >= m.r && c0 <= mc1 && c1 >= m.c) {
r0 = Math.min(r0, m.r);
r1 = Math.max(r1, mr1);
c0 = Math.min(c0, m.c);
c1 = Math.max(c1, mc1);
}
}
return { row: [r0, r1], column: [c0, c1] };
}
/**
* Pixel offset (from the grid origin) and size of a row/col span, derived from
* the sheet's column widths / row heights. Fortune-sheet defaults are 73px wide
* and 19px tall; per-index overrides live in `config.columnlen`/`config.rowlen`.
* Hidden rows/cols aren't accounted for, so this is best-effort (enough to
* decide whether the cell is on screen and where to center it).
*/
function rangePixelRect(
sheet: Sheet,
range: HighlightRange,
): { x: number; y: number; w: number; h: number } {
const cfg = (sheet.config ?? {}) as {
columnlen?: Record<string, number>;
rowlen?: Record<string, number>;
};
const colLen = cfg.columnlen ?? {};
const rowLen = cfg.rowlen ?? {};
const colW = (c: number) => colLen[c] ?? sheet.defaultColWidth ?? 73;
const rowH = (r: number) => rowLen[r] ?? sheet.defaultRowHeight ?? 19;
let x = 0;
for (let c = 0; c < range.column[0]; c++) x += colW(c);
let w = 0;
for (let c = range.column[0]; c <= range.column[1]; c++) w += colW(c);
let y = 0;
for (let r = 0; r < range.row[0]; r++) y += rowH(r);
let h = 0;
for (let r = range.row[0]; r <= range.row[1]; r++) h += rowH(r);
return { x, y, w, h };
}
type MergeInfo = { r: number; c: number; rs: number; cs: number };
type CellData = { r: number; c: number; v: Record<string, unknown> };
/**
* Expand `config.merge` onto the cells. Luckyexcel records merges in
* `config.merge` but Fortune-sheet only renders a merge when the cells carry
* `mc` (the anchor gets `{r,c,rs,cs}`; every covered cell points back with
* `{r,c}`). Without this, merged ranges render as separate single cells.
*/
function applyMergeCells(sheets: LuckyExcelSheet[]): void {
for (const sheet of sheets) {
const merges = (sheet.config as { merge?: Record<string, MergeInfo> })
?.merge;
if (!merges) continue;
if (!Array.isArray(sheet.celldata)) sheet.celldata = [];
const celldata = sheet.celldata as CellData[];
const byKey = new Map<string, CellData>();
for (const entry of celldata) {
if (typeof entry?.r === "number" && typeof entry?.c === "number") {
byKey.set(`${entry.r}_${entry.c}`, entry);
}
}
const ensureCell = (r: number, c: number): CellData => {
const key = `${r}_${c}`;
let entry = byKey.get(key);
if (!entry) {
entry = { r, c, v: {} };
celldata.push(entry);
byKey.set(key, entry);
}
if (!entry.v || typeof entry.v !== "object") entry.v = {};
return entry;
};
for (const mc of Object.values(merges)) {
ensureCell(mc.r, mc.c).v.mc = {
r: mc.r,
c: mc.c,
rs: mc.rs,
cs: mc.cs,
};
for (let rr = mc.r; rr < mc.r + mc.rs; rr++) {
for (let cc = mc.c; cc < mc.c + mc.cs; cc++) {
if (rr === mc.r && cc === mc.c) continue;
ensureCell(rr, cc).v.mc = { r: mc.r, c: mc.c };
}
}
}
}
}
/**
* Make text cells overflow into empty adjacent cells, mirroring Excel's default.
* Fortune-sheet only spills a cell's text when its `tb` (text-break) is "1";
* Luckyexcel leaves text cells clipping, so we set `tb: "1"` on unwrapped,
* non-merged text cells. Fortune-sheet still only paints the overflow over
* genuinely empty neighbors, so this matches Excel (numbers/dates keep the
* default and are not spilled).
*/
function applyExcelTextOverflow(sheets: LuckyExcelSheet[]): void {
for (const sheet of sheets) {
const celldata = sheet.celldata;
if (!Array.isArray(celldata)) continue;
for (const entry of celldata) {
const cell = (entry as { v?: Record<string, unknown> } | null)?.v;
if (!cell || typeof cell !== "object") continue;
if (cell.mc) continue; // part of a merge - leave as-is
if (cell.tb === "2") continue; // explicit wrap-text - keep
if (typeof cell.v === "string" && cell.v.length > 0) {
cell.tb = "1"; // text: overflow into empty neighbors
}
}
}
}
/**
* Tint a row/column header cell gray via Fortune-sheet's own header render
* hooks. Fortune-sheet paints the header labels (A/B/C, 1/2/3) onto the grid
* canvas with a white fill and dark text; we lay a translucent gray over that
* cell in the `afterRender…HeaderCell` pass. A low alpha darkens the white
* background into gray while leaving the dark labels legible unlike an opaque
* CSS background on the header overlay divs, which sits in front of the canvas
* and hides the labels entirely.
*/
const HEADER_TINT = "rgba(148, 163, 184, 0.18)";
function tintHeaderCell(
x: number,
y: number,
width: number,
height: number,
ctx: CanvasRenderingContext2D,
): void {
ctx.save();
ctx.fillStyle = HEADER_TINT;
ctx.fillRect(x, y, width, height);
ctx.restore();
}
/**
* Renders an Excel workbook as a read-only grid using Fortune-sheet. It fetches
* the document's raw `.xlsx`/`.xlsm`/`.xls` bytes itself (via /display) and
* converts them to Fortune-sheet data with Luckyexcel, preserving the original
* styling (fills, fonts, borders, merges).
*
* Spreadsheet citations are highlighted by cell: `highlightCells` activates the
* cited sheet tab and scrolls the A1 address/range into view, where a canvas
* hook paints the highlight.
*/
export function SpreadsheetView({
documentId,
versionId,
highlightCells,
rounded = true,
}: Props) {
const workbookRef = useRef<WorkbookInstance>(null);
// The frame element, used to reach Fortune-sheet's scrollbars for measuring
// the current scroll offset and viewport size when deciding whether to scroll.
const containerRef = useRef<HTMLDivElement>(null);
// Current highlight, read by the render hook. A ref (not state) so updating
// it never re-mounts the Workbook or changes the settings object.
const highlightRef = useRef<HighlightRange | null>(null);
const [sheets, setSheets] = useState<Sheet[] | null>(null);
const [WorkbookComponent, setWorkbookComponent] =
useState<WorkbookComponent | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch the raw workbook bytes. For spreadsheets, /display returns the
// original .xlsx/.xlsm/.xls bytes rather than a PDF rendition.
const { result, error: fetchError } = useFetchSingleDoc(
documentId,
versionId,
);
// Fortune-sheet touches browser-only APIs while loading, so keep the import
// inside the client component even though this file also owns the view.
useEffect(() => {
let cancelled = false;
import("@fortune-sheet/react").then((mod) => {
if (!cancelled) setWorkbookComponent(() => mod.Workbook);
});
return () => {
cancelled = true;
};
}, []);
// Stable key so the highlight effect only re-runs when the target changes.
const highlightKey = useMemo(
() =>
(highlightCells ?? [])
.map((h) => `${h.sheet ?? ""}!${h.cell ?? ""}`)
.join("|"),
[highlightCells],
);
// Parse the workbook with Luckyexcel, which converts the .xlsx to
// Fortune-sheet data while preserving styling (fills, fonts, borders,
// alignment, column widths).
useEffect(() => {
if (!result) return;
if (result.type !== "spreadsheet") {
setError("This spreadsheet could not be displayed.");
return;
}
let cancelled = false;
setSheets(null);
setError(null);
try {
const file = new File([result.buffer], "spreadsheet.xlsx");
LuckyExcel.transformExcelToLucky(file, (exportJson) => {
if (cancelled) return;
if (exportJson?.sheets?.length) {
applyMergeCells(exportJson.sheets);
applyExcelTextOverflow(exportJson.sheets);
setSheets(exportJson.sheets as unknown as Sheet[]);
} else {
setError("This spreadsheet could not be displayed.");
}
});
} catch {
if (!cancelled)
setError("This spreadsheet could not be displayed.");
}
return () => {
cancelled = true;
};
}, [result]);
// Draw the citation highlight on the canvas. Stable identity so the Workbook
// settings never change; it reads the live target from `highlightRef`. We use
// this instead of `setSelection`, whose in-place mutation of the range object
// crashes under React Strict Mode's double-invoked immer producer.
const afterRenderCell = useCallback(
(
_cell: Cell | null,
info: {
row: number;
column: number;
startX: number;
startY: number;
endX: number;
endY: number;
},
ctx: CanvasRenderingContext2D,
) => {
const range = highlightRef.current;
if (!range) return;
if (
info.row < range.row[0] ||
info.row > range.row[1] ||
info.column < range.column[0] ||
info.column > range.column[1]
) {
return;
}
const w = info.endX - info.startX;
const h = info.endY - info.startY;
ctx.save();
ctx.fillStyle = "rgba(59, 130, 246, 0.16)";
ctx.fillRect(info.startX, info.startY, w, h);
ctx.strokeStyle = "#3b82f6";
ctx.lineWidth = 2;
ctx.strokeRect(info.startX + 1, info.startY + 1, w - 2, h - 2);
ctx.restore();
},
[],
);
const hooks = useMemo(
() => ({
afterRenderCell,
// Tint the header strips gray while keeping the A/B/C, 1/2/3 labels
// visible (see tintHeaderCell). Column cells fill from y=0; row cells
// fill from x=0 — matching Fortune-sheet's own default header rects.
afterRenderColumnHeaderCell: (
_char: string,
_idx: number,
left: number,
width: number,
height: number,
ctx: CanvasRenderingContext2D,
) => tintHeaderCell(left, 0, width, height, ctx),
afterRenderRowHeaderCell: (
_num: string,
_idx: number,
top: number,
width: number,
height: number,
ctx: CanvasRenderingContext2D,
) => tintHeaderCell(0, top, width, height, ctx),
}),
[afterRenderCell],
);
// Activate the cited sheet, bring the cell into view, and repaint the
// highlight. We only scroll when the cell is off screen (centering it);
// when it's already visible we leave the viewport put and force a redraw so
// the `afterRenderCell` hook repaints the new highlight (and clears the old
// one). Both paths must trigger a redraw: `scroll()` repaints because the
// position changes, and the synthetic "resize" repaints in place via
// Fortune-sheet's window resize handler.
useEffect(() => {
if (!sheets) return;
const target = highlightCells?.[0];
const sheetIndex = target?.sheet
? Math.max(
0,
sheets.findIndex((s) => s.name === target.sheet),
)
: 0;
const parsed = target?.cell ? parseRange(target.cell) : null;
const range = parsed
? expandRangeForMerges(sheets[sheetIndex], parsed)
: null;
highlightRef.current = range;
if (!range && !target?.sheet) return;
const timer = window.setTimeout(() => {
const inst = workbookRef.current;
if (!inst) return;
try {
inst.activateSheet({ index: sheetIndex });
if (!range) return;
const container = containerRef.current;
const sbX = container?.querySelector<HTMLElement>(
".luckysheet-scrollbar-x",
);
const sbY = container?.querySelector<HTMLElement>(
".luckysheet-scrollbar-y",
);
// Without the scrollbars we can't measure the viewport; fall back to
// corner-scrolling, which at least brings the cell in and repaints.
if (!sbX || !sbY) {
inst.scroll({
targetRow: range.row[0],
targetColumn: range.column[0],
});
return;
}
const rect = rangePixelRect(sheets[sheetIndex], range);
const curLeft = sbX.scrollLeft;
const curTop = sbY.scrollTop;
const viewW = sbX.clientWidth;
const viewH = sbY.clientHeight;
const visible =
rect.x >= curLeft &&
rect.x + rect.w <= curLeft + viewW &&
rect.y >= curTop &&
rect.y + rect.h <= curTop + viewH;
if (visible) {
// On screen: keep the viewport still, just repaint the highlight.
window.dispatchEvent(new Event("resize"));
} else {
// Off screen: center the cell. scroll() re-renders and repaints.
inst.scroll({
scrollLeft: Math.max(
0,
Math.round(rect.x - (viewW - rect.w) / 2),
),
scrollTop: Math.max(
0,
Math.round(rect.y - (viewH - rect.h) / 2),
),
});
}
} catch {
/* highlighting is best-effort */
}
}, 200);
return () => window.clearTimeout(timer);
}, [sheets, highlightCells, highlightKey]);
const frameClass = `fortune-sheet-viewer relative flex flex-col flex-1 min-h-0 overflow-hidden ${rounded ? "rounded-lg" : ""}`;
const message =
error ?? (fetchError ? "Failed to load spreadsheet." : null);
if (message) {
return (
<div className={frameClass}>
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-500">
{message}
</div>
</div>
);
}
if (!sheets || !WorkbookComponent) {
return (
<div className={frameClass}>
<div className="flex h-full items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
</div>
);
}
return (
<div ref={containerRef} className={frameClass}>
<div className="relative min-h-0 flex-1">
<WorkbookComponent
ref={workbookRef}
data={sheets}
hooks={hooks}
allowEdit={false}
showToolbar={false}
showFormulaBar={false}
/>
<style jsx global>{`
/* The row/col header strips are transparent overlays over
the label canvas leave them so the labels show. Only the
corner (no label) gets a matching gray background here; the
header strips are tinted on the canvas via render hooks. */
.fortune-sheet-viewer .fortune-left-top {
background-color: #eceef2;
}
.fortune-sheet-viewer .fortune-row-header-hover,
.fortune-sheet-viewer .fortune-col-header-hover {
background-color: rgba(209, 213, 219, 0.65);
}
.fortune-sheet-viewer .fortune-row-header-selected,
.fortune-sheet-viewer .fortune-col-header-selected {
background-color: rgba(156, 163, 175, 0.28);
}
`}</style>
</div>
</div>
);
}
export default SpreadsheetView;

View file

@ -0,0 +1,130 @@
const HIGHLIGHT_CLASS = "docx-text-highlight";
const IGNORED_TEXT_SELECTOR = ".star-pagination,.case-page-number";
function onlyLetters(s: string): string {
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
}
function toOrigPos(text: string, strippedPos: number): number {
let count = 0;
for (let k = 0; k < text.length; k++) {
if (/[a-zA-Z0-9]/.test(text[k])) {
if (count === strippedPos) return k;
count++;
}
}
return text.length;
}
function collectTextNodes(root: HTMLElement): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node: Node) {
const p = node.parentElement;
if (!p) return NodeFilter.FILTER_REJECT;
const tag = p.tagName;
if (tag === "STYLE" || tag === "SCRIPT")
return NodeFilter.FILTER_REJECT;
if (p.closest(IGNORED_TEXT_SELECTOR))
return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
const out: Text[] = [];
let cur = walker.nextNode() as Text | null;
while (cur) {
out.push(cur);
cur = walker.nextNode() as Text | null;
}
return out;
}
export function clearDocxQuoteHighlights(root: HTMLElement): void {
root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((span) => {
const parent = span.parentNode;
if (!parent) return;
while (span.firstChild) parent.insertBefore(span.firstChild, span);
parent.removeChild(span);
});
root.normalize();
}
/**
* Highlight the given quote text inside `root` (a docx-preview output).
* Quote is split on ellipsis variants; each segment is located via
* letters-only substring matching, so whitespace/punctuation differences
* between the LLM's quote and the rendered text don't break matching.
*
* Returns the first highlight span if any match was found, for
* scroll-into-view by the caller.
*/
export function highlightDocxQuote(
root: HTMLElement,
quote: string,
): HTMLElement | null {
clearDocxQuoteHighlights(root);
if (!quote) return null;
const segments = quote
.split(/\.{3}|…/)
.map(onlyLetters)
.filter((s) => s.length > 0);
if (segments.length === 0) return null;
const textNodes = collectTextNodes(root);
const nodeStartInFull: number[] = [];
const nodeStrippedLen: number[] = [];
let fullStripped = "";
for (const node of textNodes) {
const stripped = onlyLetters(node.data);
nodeStartInFull.push(fullStripped.length);
nodeStrippedLen.push(stripped.length);
fullStripped += stripped;
}
type Range = { nodeIdx: number; origStart: number; origEnd: number };
const ranges: Range[] = [];
for (const segment of segments) {
const searchKey = segment.slice(0, 30);
const matchPos = fullStripped.indexOf(searchKey);
if (matchPos < 0) continue;
const matchEnd = matchPos + segment.length;
for (let i = 0; i < textNodes.length; i++) {
const start = nodeStartInFull[i];
const end = start + nodeStrippedLen[i];
if (matchPos >= end || matchEnd <= start) continue;
const localStart = Math.max(0, matchPos - start);
const localEnd = Math.min(nodeStrippedLen[i], matchEnd - start);
const text = textNodes[i].data;
const origStart = toOrigPos(text, localStart);
const origEnd = toOrigPos(text, localEnd);
if (origStart >= origEnd) continue;
ranges.push({ nodeIdx: i, origStart, origEnd });
}
}
if (ranges.length === 0) return null;
// Apply in reverse document order so splits don't shift earlier ranges.
ranges.sort((a, b) => {
if (a.nodeIdx !== b.nodeIdx) return b.nodeIdx - a.nodeIdx;
return b.origStart - a.origStart;
});
const spans: HTMLElement[] = [];
for (const r of ranges) {
const node = textNodes[r.nodeIdx];
const mid = node.splitText(r.origStart);
mid.splitText(r.origEnd - r.origStart);
const span = document.createElement("span");
span.className = HIGHLIGHT_CLASS;
mid.parentNode?.insertBefore(span, mid);
span.appendChild(mid);
spans.push(span);
}
// Because we processed ranges in reverse order, the earliest-in-document
// highlight is the last one we pushed.
return spans[spans.length - 1] ?? null;
}

View file

@ -0,0 +1,124 @@
let pdfjsLib: typeof import("pdfjs-dist") | null = null;
export async function getPdfJs() {
if (pdfjsLib) return pdfjsLib;
pdfjsLib = await import("pdfjs-dist");
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
return pdfjsLib;
}
export const STANDARD_FONT_DATA_URL =
"https://unpkg.com/pdfjs-dist@4.10.38/standard_fonts/";
const HIGHLIGHT_CLASS = "pdf-text-highlight";
const ORIGINAL_TEXT_ATTR = "data-original-text";
// Strip everything except alphanumerics (a-z A-Z 0-9) for robust matching
function onlyLetters(s: string): string {
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// Given a position in the stripped (letters-only) version of `original`,
// return the corresponding index in `original`.
function strippedPosToOriginal(original: string, strippedPos: number): number {
let count = 0;
for (let i = 0; i < original.length; i++) {
if (/[a-zA-Z0-9]/.test(original[i])) {
if (count === strippedPos) return i;
count++;
}
}
return original.length;
}
export function clearHighlights(textDivs: HTMLElement[]) {
for (const div of textDivs) {
if (div.hasAttribute(ORIGINAL_TEXT_ATTR)) {
div.textContent = div.getAttribute(ORIGINAL_TEXT_ATTR)!;
div.removeAttribute(ORIGINAL_TEXT_ATTR);
}
}
}
export async function highlightQuote(
textDivs: HTMLElement[],
quote: string,
): Promise<boolean> {
clearHighlights(textDivs);
// Split on ellipsis variants to highlight each segment separately
const segments = quote
.split(/\.{3}|…/)
.map((s) => onlyLetters(s))
.filter((s) => s.length > 0);
// Build the stripped full text and track each div's start position within it.
// Also keep original div texts for display.
const divOrigTexts: string[] = []; // original text for innerHTML slicing
const divStripped: string[] = []; // letters-only version for matching
const divStartInFull: number[] = []; // start index in fullStripped
let fullStripped = "";
for (let i = 0; i < textDivs.length; i++) {
const orig = textDivs[i].textContent ?? "";
divOrigTexts.push(orig);
const stripped = onlyLetters(orig);
divStripped.push(stripped);
divStartInFull.push(fullStripped.length);
fullStripped += stripped;
}
// Map: divIndex -> [strippedLocalStart, strippedLocalEnd]
const divHighlightRanges = new Map<number, [number, number]>();
for (const segment of segments) {
const searchKey = segment.slice(0, 30);
const matchPos = fullStripped.indexOf(searchKey);
if (matchPos === -1) {
continue;
}
const matchEnd = matchPos + segment.length;
for (let i = 0; i < textDivs.length; i++) {
const divStart = divStartInFull[i];
const divEnd = divStart + divStripped[i].length;
if (matchPos >= divEnd || matchEnd <= divStart) continue;
const localStart = Math.max(0, matchPos - divStart);
const localEnd = Math.min(
divStripped[i].length,
matchEnd - divStart,
);
divHighlightRanges.set(i, [localStart, localEnd]);
}
}
if (divHighlightRanges.size === 0) return false;
for (const [idx, [strStart, strEnd]] of divHighlightRanges) {
const div = textDivs[idx];
const orig = divOrigTexts[idx];
// Map stripped positions back to original character positions
const origStart = strippedPosToOriginal(orig, strStart);
const origEnd = strippedPosToOriginal(orig, strEnd);
div.setAttribute(ORIGINAL_TEXT_ATTR, orig);
div.innerHTML =
escapeHtml(orig.slice(0, origStart)) +
`<span class="${HIGHLIGHT_CLASS}">${escapeHtml(orig.slice(origStart, origEnd))}</span>` +
escapeHtml(orig.slice(origEnd));
}
return true;
}

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
interface SiteLogoProps {
size?: "sm" | "md" | "lg" | "xl";

View file

@ -1,21 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Plus, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
import { FORMAT_OPTIONS } from "./columnFormat";
import { TAG_COLORS } from "./pillUtils";
import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ModalSelect } from "../modals/ModalSelect";
import { ModalTextarea } from "../modals/ModalTextarea";
import { ModalTextInput } from "../modals/ModalTextInput";
interface ColumnDraft {
name: string;
@ -45,7 +41,9 @@ interface Props {
export function AddColumnModal({ open, existingCount, onClose, onAdd, editingColumn, onSave, onDelete }: Props) {
const isEditing = !!editingColumn;
const formId = "add-column-modal-form";
const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_DRAFT }]);
const [collapsedIndices, setCollapsedIndices] = useState<number[]>([]);
const [generatingIndices, setGeneratingIndices] = useState<number[]>([]);
const [presetsOpenIndex, setPresetsOpenIndex] = useState<number | null>(
null,
@ -65,6 +63,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
} else {
setColumns([{ ...EMPTY_DRAFT }]);
}
setCollapsedIndices([]);
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
@ -86,6 +85,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
function resetForm() {
setColumns([{ ...EMPTY_DRAFT }]);
setCollapsedIndices([]);
setGeneratingIndices([]);
}
@ -110,6 +110,24 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
? [{ ...EMPTY_DRAFT }]
: prev.filter((_, i) => i !== index),
);
setCollapsedIndices((prev) =>
prev
.filter((collapsedIndex) => collapsedIndex !== index)
.map((collapsedIndex) =>
collapsedIndex > index
? collapsedIndex - 1
: collapsedIndex,
),
);
}
function toggleColumnCollapsed(index: number) {
setCollapsedIndices((prev) =>
prev.includes(index)
? prev.filter((collapsedIndex) => collapsedIndex !== index)
: [...prev, index],
);
setPresetsOpenIndex(null);
}
function commitTag(index: number) {
@ -191,35 +209,90 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
onClose();
}
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-2">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Tabular Review</span>
<span></span>
<span>{isEditing ? "Edit column" : "New column"}</span>
</div>
<button
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-col min-h-0 flex-1"
>
{/* Body */}
<div className="px-6 pt-3 pb-5 space-y-5 overflow-y-auto flex-1">
return (
<Modal
open={open}
onClose={handleClose}
breadcrumbs={[
"Tabular Review",
isEditing ? "Edit column" : "New column",
]}
primaryAction={{
label: isEditing ? "Save changes" : "Add columns",
type: "submit",
form: formId,
disabled: columns.some(
(col) => !col.name.trim() || !col.prompt.trim(),
),
}}
cancelAction={{ label: "Cancel", onClick: handleClose }}
secondaryAction={
isEditing && onDelete
? {
label: "Delete",
variant: "danger",
onClick: onDelete,
}
: undefined
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3">
{columns.map((column, index) => (
<div
key={index}
className="rounded-xl border border-gray-200 p-4"
className="relative"
>
{(() => {
const nameInputId = `column-${index}-name`;
const formatInputId = `column-${index}-format`;
const tagInputId = `column-${index}-tag`;
const promptInputId = `column-${index}-prompt`;
const isCollapsed =
collapsedIndices.includes(index);
return (
<>
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={() =>
toggleColumnCollapsed(
index,
)
}
aria-expanded={!isCollapsed}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-gray-300"
>
<ChevronDown
className={`h-4 w-4 shrink-0 text-gray-600 transition-transform ${isCollapsed ? "-rotate-90" : ""}`}
/>
<h3 className="font-serif text-2xl text-gray-950">
Column {index + 1}
</h3>
</button>
{columns.length > 1 && (
<button
type="button"
onClick={() =>
removeColumn(index)
}
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
aria-label="Remove column"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{!isCollapsed && (
<>
<ModalFieldLabel htmlFor={nameInputId}>
Column title
</ModalFieldLabel>
{/* Name row */}
<div className="flex items-start gap-2">
{/* Input + preset dropdown anchored to this wrapper */}
@ -231,8 +304,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
: null
}
>
<input
<ModalTextInput
id={nameInputId}
type="text"
variant="minimal"
value={column.name}
onChange={(e) => {
const name = e.target.value;
@ -253,7 +328,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
});
}}
placeholder="Column name"
className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
className="flex-1"
autoFocus={index === 0}
/>
<button
@ -316,74 +391,39 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
</div>
)}
</div>
{columns.length > 1 && (
<button
type="button"
onClick={() => removeColumn(index)}
className="mt-1.5 rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Format */}
<div className="mt-4">
<ModalFieldLabel className="text-gray-500">
<ModalFieldLabel htmlFor={formatInputId}>
Format
</ModalFieldLabel>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none">
<span className="flex items-center gap-2">
{(() => {
const Icon = formatIcon(
column.format,
);
return (
<Icon className="h-3.5 w-3.5 text-gray-400" />
);
})()}
{formatLabel(column.format)}
</span>
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[200]"
>
<DropdownMenuRadioGroup
value={column.format}
onValueChange={(v) =>
updateColumn(index, {
format: v as ColumnFormat,
tags: [],
tagInput: "",
})
}
>
{FORMAT_OPTIONS.map((o) => (
<DropdownMenuRadioItem
key={o.value}
value={o.value}
>
<o.icon className="h-3.5 w-3.5 text-gray-400" />
{o.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ModalSelect
id={formatInputId}
value={column.format}
options={FORMAT_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
icon: option.icon,
iconClassName: option.iconClassName,
}))}
onChange={(value) =>
updateColumn(index, {
format: value as ColumnFormat,
tags: [],
tagInput: "",
})
}
/>
</div>
{/* Tag input */}
{column.format === "tag" && (
<div className="mt-3">
<ModalFieldLabel className="text-gray-500">
<ModalFieldLabel htmlFor={tagInputId}>
Tags
</ModalFieldLabel>
<div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400">
<div className="mt-1 flex flex-wrap gap-1.5 rounded-xl border border-white/70 bg-white/55 px-2 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl">
{column.tags.map((tag, tagIdx) => (
<span
key={tag}
@ -410,8 +450,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
</button>
</span>
))}
<input
<ModalTextInput
id={tagInputId}
type="text"
variant="minimal"
value={column.tagInput}
onChange={(e) =>
updateColumn(index, {
@ -424,7 +466,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
}
onBlur={() => commitTag(index)}
placeholder="Add tag…"
className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none"
className="min-w-[80px] flex-1 bg-transparent font-sans text-sm text-gray-700 shadow-none placeholder:text-gray-400"
/>
</div>
<p className="mt-1 text-xs text-gray-400">
@ -435,7 +477,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
{/* Prompt */}
<div className="mt-4 flex items-center justify-between">
<ModalFieldLabel className="mb-0 text-gray-500">
<ModalFieldLabel
htmlFor={promptInputId}
className="mb-0"
>
Prompt
</ModalFieldLabel>
<button
@ -457,7 +502,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
Auto-Generate Prompt
</button>
</div>
<textarea
<ModalTextarea
id={promptInputId}
rows={6}
value={column.prompt}
onChange={(e) =>
@ -466,8 +512,13 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
})
}
placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…"
className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed"
className="mt-2 min-h-36"
/>
</>
)}
</>
);
})()}
</div>
))}
@ -481,43 +532,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
Add another column
</button>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<div>
{isEditing && onDelete && (
<button
type="button"
onClick={onDelete}
className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors"
>
Delete
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={columns.some(
(col) => !col.name.trim() || !col.prompt.trim(),
)}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
{isEditing ? "Save changes" : "Add columns"}
</button>
</div>
</div>
</form>
</div>
</div>,
document.body,
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,465 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2, Upload } from "lucide-react";
import type { Document, Project, Workflow } from "../shared/types";
import {
getProject,
listProjects,
listStandaloneDocuments,
listWorkflows,
uploadProjectDocument,
uploadStandaloneDocument,
} from "@/app/lib/mikeApi";
import { FileDirectory } from "../shared/FileDirectory";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import { ModalSelect } from "../modals/ModalSelect";
import { ModalTextInput } from "../modals/ModalTextInput";
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
interface Props {
open: boolean;
onClose: () => void;
onAdd: (
title: string,
projectId?: string,
documentIds?: string[],
columnsConfig?: Workflow["columns_config"],
) => void;
projects?: Project[];
/** When provided, skip the project/directory picker and show only these docs */
projectDocs?: Document[];
projectName?: string;
projectCmNumber?: string | null;
}
export function NewTRModal({
open,
onClose,
onAdd,
projects = [],
projectDocs: fixedProjectDocs,
projectName,
projectCmNumber,
}: Props) {
const isProjectMode = fixedProjectDocs !== undefined;
const [step, setStep] = useState<"details" | "documents">("details");
const [title, setTitle] = useState("");
const [underProject, setUnderProject] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState("");
// Project-scoped docs (when underProject is true and no fixedProjectDocs)
const [projectDocs, setProjectDocs] = useState<Document[]>([]);
const [loadingDocs, setLoadingDocs] = useState(false);
// Full directory (when underProject is false)
const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]);
const [directoryProjects, setDirectoryProjects] = useState<Project[]>(
[],
);
const [loadingDirectory, setLoadingDirectory] = useState(false);
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
new Set(),
);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Workflow templates
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const formId = "new-tabular-review-modal-form";
useEffect(() => {
if (!open) return;
setLoadingWorkflows(true);
listWorkflows("tabular")
.then((workflows) => {
devLog("[workflows/ui:tabular-review-modal] loaded", {
workflowCount: workflows.length,
systemCount: workflows.filter((workflow) => workflow.is_system)
.length,
sample: workflows.slice(0, 5).map((workflow) => ({
id: workflow.id,
title: workflow.metadata.title,
type: workflow.metadata.type,
user_id: workflow.user_id,
is_system: workflow.is_system,
is_owner: workflow.is_owner,
})),
});
setWorkflows(workflows);
})
.catch((error) => {
devLog(
"[workflows/ui:tabular-review-modal] failed",
error,
);
setWorkflows([]);
})
.finally(() => setLoadingWorkflows(false));
if (isProjectMode) {
setSelectedDocIds(
new Set((fixedProjectDocs ?? []).map((d) => d.id)),
);
return;
}
setLoadingDirectory(true);
// /projects only returns counts, not the documents array — fetch
// each project in parallel so FileDirectory can render the docs
// when the user expands a folder.
Promise.all([listStandaloneDocuments(), listProjects()])
.then(async ([docs, projs]) => {
setStandaloneDocs(
[...docs].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
);
const fullProjects = await Promise.all(
projs.map((p) => getProject(p.id)),
);
setDirectoryProjects(fullProjects);
})
.catch(() => {
setStandaloneDocs([]);
setDirectoryProjects([]);
})
.finally(() => setLoadingDirectory(false));
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null;
function handleClose() {
setStep("details");
setTitle("");
setUnderProject(false);
setSelectedProjectId("");
setProjectDocs([]);
setStandaloneDocs([]);
setDirectoryProjects([]);
setSelectedDocIds(new Set());
setSelectedWorkflowId(null);
onClose();
}
function submitterValue(e: React.FormEvent<HTMLFormElement>) {
return (
(e.nativeEvent as SubmitEvent).submitter as
| HTMLButtonElement
| null
)?.value;
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim()) return;
if (underProject && !selectedProjectId) return;
if (step === "details" || submitterValue(e) !== "create-review") {
setStep("documents");
return;
}
const selectedWorkflow = workflows.find(
(w) => w.id === selectedWorkflowId,
);
onAdd(
title.trim(),
underProject ? selectedProjectId : undefined,
selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
selectedWorkflow?.columns_config ?? undefined,
);
handleClose();
}
async function handleSelectProject(projectId: string) {
setSelectedProjectId(projectId);
setProjectDocs([]);
setSelectedDocIds(new Set());
setLoadingDocs(true);
try {
const proj = await getProject(projectId);
const docs = (proj.documents ?? []).filter(
(d) => d.status === "ready",
);
setProjectDocs(docs);
setSelectedDocIds(new Set(docs.map((d) => d.id)));
} finally {
setLoadingDocs(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) =>
underProject && selectedProjectId
? uploadProjectDocument(selectedProjectId, f)
: uploadStandaloneDocument(f),
),
);
if (underProject && selectedProjectId) {
setProjectDocs((prev) => [...uploaded, ...prev]);
} else {
setStandaloneDocs((prev) => [...uploaded, ...prev]);
}
uploaded.forEach((d) =>
setSelectedDocIds((prev) => new Set([...prev, d.id])),
);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
const workflowOptions = [
{
value: "",
label: loadingWorkflows
? "Loading templates..."
: "No template - start from scratch",
},
...workflows.map((workflow) => ({
value: workflow.id,
label: workflow.metadata.title,
})),
];
const projectOptions = projects.length
? projects.map((project) => ({
value: project.id,
label:
project.name +
(project.cm_number ? ` (#${project.cm_number})` : ""),
}))
: [{ value: "", label: "No projects found" }];
// What to show in the directory depends on mode and toggle state
const directoryStandalone = isProjectMode
? (fixedProjectDocs ?? [])
: underProject
? []
: standaloneDocs;
const directoryFolders = isProjectMode
? []
: underProject
? []
: directoryProjects;
const flatProjectDocs: Document[] =
!isProjectMode && underProject ? projectDocs : [];
const directoryLoading = isProjectMode
? false
: underProject
? loadingDocs
: loadingDirectory;
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
const breadcrumbs =
isProjectMode && projectName
? [
"Projects",
`${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`,
"New Tabular Review",
]
: ["Tabular Reviews", "New Tabular Review"];
return (
<Modal
open={open}
onClose={handleClose}
breadcrumbs={[
...breadcrumbs,
step === "details" ? "Details" : "Add Documents",
]}
secondaryAction={
step === "documents"
? {
label: uploading ? "Uploading..." : "Upload",
icon: uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
),
onClick: () => fileInputRef.current?.click(),
disabled: uploading,
}
: undefined
}
cancelAction={
step === "documents"
? {
label: "Back",
onClick: () => setStep("details"),
disabled: uploading,
}
: undefined
}
primaryAction={
step === "details"
? {
label: "Next",
type: "button",
onClick: (event) => {
event.preventDefault();
setStep("documents");
},
disabled:
!title.trim() ||
(underProject && !selectedProjectId),
}
: {
label: "Create",
type: "submit",
form: formId,
name: "modalAction",
value: "create-review",
disabled:
!title.trim() ||
(underProject && !selectedProjectId),
}
}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
multiple
className="hidden"
onChange={handleUpload}
/>
<form
id={formId}
onSubmit={handleSubmit}
className="flex flex-col min-h-0 flex-1"
>
{step === "details" ? (
<div className="space-y-6">
<div>
<ModalFieldLabel htmlFor="new-tr-title">
Review name
</ModalFieldLabel>
<ModalTextInput
id="new-tr-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Review name"
variant="minimal"
className="placeholder:text-gray-400"
autoFocus
/>
</div>
{/* Workflow template */}
<div>
<ModalFieldLabel as="p">
Workflow template
</ModalFieldLabel>
<ModalSelect
id="new-tr-workflow-template"
value={selectedWorkflowId ?? ""}
options={workflowOptions}
onChange={(value) =>
setSelectedWorkflowId(value || null)
}
disabled={loadingWorkflows}
/>
</div>
{/* Create under a project toggle */}
{!isProjectMode && (
<div className="space-y-3">
<ModalFieldLabel as="p">
Project
</ModalFieldLabel>
<button
type="button"
onClick={() => {
const next = !underProject;
setUnderProject(next);
if (!next) {
setSelectedProjectId("");
setProjectDocs([]);
setSelectedDocIds(new Set());
}
}}
className="flex w-fit items-center gap-2.5"
>
<span
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-100"}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${underProject ? "translate-x-4" : "translate-x-0"}`}
/>
</span>
<span className="text-sm text-gray-600">
Create under a project
</span>
</button>
{underProject && (
<ModalSelect
id="new-tr-project"
value={selectedProjectId}
options={projectOptions}
onChange={(value) => {
if (value) {
void handleSelectProject(value);
}
}}
placeholder="Select project..."
disabled={projects.length === 0}
/>
)}
</div>
)}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
{showDirectory && (
<FileDirectory
standaloneDocs={
isProjectMode
? directoryStandalone
: underProject
? flatProjectDocs
: directoryStandalone
}
directoryProjects={
isProjectMode
? []
: underProject
? []
: directoryFolders
}
loading={directoryLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
emptyMessage={
isProjectMode || underProject
? "No ready documents in this project"
: "No documents yet"
}
searchable
searchAutoFocus
showProjectTabs={!isProjectMode && !underProject}
/>
)}
</div>
)}
</form>
</Modal>
);
}

View file

@ -13,7 +13,7 @@ import {
ChevronLeft,
Trash2,
} from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import {
streamTabularChat,
getTabularChats,
@ -27,14 +27,14 @@ import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
import { ModelToggle } from "../assistant/ModelToggle";
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
import { PreResponseWrapper } from "../assistant/PreResponseWrapper";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
getModelProvider,
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
// ---------------------------------------------------------------------------
// Types

View file

@ -1,7 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Loader2, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Loader2, MoreHorizontal, Plus, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
@ -12,10 +13,17 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/components/ui/dropdown-menu";
import { PillButton } from "@/app/components/ui/pill-button";
// Liquid-glass field styling shared by the menu's inputs/controls, matching the
// modal's glass treatment (translucent white over the light-gray panel).
const GLASS_FIELD =
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl";
export interface TREditColumnMenuProps {
column: ColumnConfig;
closeSignal?: number;
disabled?: boolean;
onSave: (column: ColumnConfig) => void | Promise<void>;
onDelete: (columnIndex: number) => void | Promise<void>;
@ -23,11 +31,13 @@ export interface TREditColumnMenuProps {
export function TREditColumnMenu({
column,
closeSignal,
disabled,
onSave,
onDelete,
}: TREditColumnMenuProps) {
const [open, setOpen] = useState(false);
const menuId = useId();
const [name, setName] = useState(column.name);
const [prompt, setPrompt] = useState(column.prompt);
const [format, setFormat] = useState<ColumnFormat>(column.format ?? "text");
@ -36,6 +46,58 @@ export function TREditColumnMenu({
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [generating, setGenerating] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Fixed-position coords for the portaled menu. The menu is rendered into
// document.body so it isn't clipped by the header's overflow-hidden (which
// exists for horizontal scroll-sync). We size/position it to span the column
// header cell (with a min width for usability) and keep it pinned as the
// table scrolls or the window resizes.
const [menuPos, setMenuPos] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
useEffect(() => {
if (!open) {
setMenuPos(null);
return;
}
const update = () => {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
// Span this column's header cell so the panel's left/right edges line
// up with the column. Falls back to the trigger button when no column
// ancestor is found. A min width keeps the form usable on narrow
// columns (there it just extends leftward from the column's right
// edge). Positioning via left (same coordinate space as
// getBoundingClientRect) avoids scrollbar/innerWidth offsets.
const colRect = buttonRef.current
?.closest("[data-tr-col-header]")
?.getBoundingClientRect();
const rightEdge = colRect?.right ?? rect.right;
const width = Math.max(colRect?.width ?? 288, 288); // 288 = w-72
setMenuPos({
top: rect.bottom + 6, // mt-1.5
left: Math.max(8, rightEdge - width),
width,
});
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open]);
useEffect(() => {
if (closeSignal === undefined) return;
const timeout = window.setTimeout(() => setOpen(false), 0);
return () => window.clearTimeout(timeout);
}, [closeSignal]);
useEffect(() => {
if (!open) {
@ -47,6 +109,45 @@ export function TREditColumnMenu({
}
}, [column.name, column.prompt, column.format, column.tags, open]);
// Only one edit-column menu should be open at a time. Broadcast when this
// one opens and close ourselves when another one broadcasts.
useEffect(() => {
if (open) {
window.dispatchEvent(
new CustomEvent("tr-edit-column-menu-open", { detail: menuId }),
);
}
}, [open, menuId]);
useEffect(() => {
const onOtherOpen = (e: Event) => {
if ((e as CustomEvent<string>).detail !== menuId) setOpen(false);
};
window.addEventListener("tr-edit-column-menu-open", onOtherOpen);
return () =>
window.removeEventListener("tr-edit-column-menu-open", onOtherOpen);
}, [menuId]);
// Close on click outside the panel. Ignore the trigger (it toggles) and any
// Radix popper (e.g. the Format dropdown, which portals outside the panel).
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
panelRef.current?.contains(target) ||
buttonRef.current?.contains(target) ||
(target instanceof Element &&
target.closest("[data-radix-popper-content-wrapper]"))
) {
return;
}
setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
function commitTag() {
const tag = tagInput.trim();
if (!tag) {
@ -112,6 +213,7 @@ export function TREditColumnMenu({
return (
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
<button
ref={buttonRef}
onClick={(e) => {
e.stopPropagation();
if (disabled) return;
@ -127,19 +229,28 @@ export function TREditColumnMenu({
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div
className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-2xl border border-white/70 bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl"
onClick={(e) => e.stopPropagation()}
>
{open &&
menuPos &&
createPortal(
<div
ref={panelRef}
className="fixed z-[40] rounded-3xl border border-white/70 bg-gray-50/95 p-3 shadow-[0_14px_40px_rgba(15,23,42,0.071),0_5px_14px_rgba(15,23,42,0.047)] backdrop-blur-3xl"
style={{
top: menuPos.top,
left: menuPos.left,
width: menuPos.width,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-gray-800">
<p className="font-serif text-lg font-medium text-gray-900">
Edit Column
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
aria-label="Close"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
>
<X className="h-3.5 w-3.5" />
</button>
@ -151,7 +262,7 @@ export function TREditColumnMenu({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-gray-800 text-xs font-normal focus:border-gray-400 focus:outline-none"
className={`mt-1 w-full rounded-lg px-2 py-1 text-xs font-normal text-gray-800 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`}
/>
{/* Format */}
@ -161,7 +272,9 @@ export function TREditColumnMenu({
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex w-full items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 hover:border-gray-400 focus:outline-none">
<button
className={`mt-1 flex w-full items-center justify-between rounded-lg px-2 py-1 text-xs text-gray-700 transition-colors hover:bg-white/75 focus:outline-none ${GLASS_FIELD}`}
>
<span className="flex items-center gap-1.5">
{(() => {
const Icon = formatIcon(format);
@ -176,6 +289,7 @@ export function TREditColumnMenu({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[50] border-white/70 bg-white/75 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl"
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
}}
@ -206,7 +320,9 @@ export function TREditColumnMenu({
{/* Tag input */}
{format === "tag" && (
<div className="mt-2">
<div className="flex flex-wrap gap-1 rounded-md border border-gray-200 px-2 py-1 focus-within:border-gray-400 min-h-[28px]">
<div
className={`flex min-h-[28px] flex-wrap gap-1 rounded-lg px-2 py-1 transition-colors focus-within:bg-white/70 ${GLASS_FIELD}`}
>
{tags.map((tag, tagIdx) => (
<span
key={tag}
@ -269,20 +385,18 @@ export function TREditColumnMenu({
rows={6}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="mt-2 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-normal text-gray-800 placeholder-gray-300 focus:border-gray-400 focus:outline-none resize-none leading-relaxed"
className={`mt-2 w-full resize-none rounded-lg px-3 py-2 text-xs font-normal leading-relaxed text-gray-800 placeholder-gray-300 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<button
type="button"
<PillButton
tone="danger"
onClick={handleDelete}
disabled={deleting || saving}
className="inline-flex items-center gap-1.5 text-xs text-red-500 transition-colors hover:text-red-600 disabled:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</PillButton>
<button
type="button"
onClick={handleSave}
@ -298,8 +412,9 @@ export function TREditColumnMenu({
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
)}
</div>,
document.body,
)}
</div>
);
}

View file

@ -23,7 +23,7 @@ import { getPillClass } from "./pillUtils";
import { PdfView } from "../shared/views/PdfView";
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
import { DocxView } from "../shared/views/DocxView";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
function isDocxDocument(d: {
file_type?: string | null;

View file

@ -1,6 +1,6 @@
"use client";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { Loader2, Plus, Table2, Upload } from "lucide-react";
import type {
ColumnConfig,
@ -75,18 +75,31 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
const stickyCellBg = "bg-[#fafbfc]";
const scrollContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const lastScrollLeftRef = useRef(0);
const [scrollCloseSignal, setScrollCloseSignal] = useState(0);
function syncHeader() {
if (headerRef.current && scrollContainerRef.current) {
headerRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
}
}
function handleRowsScroll() {
syncHeader();
const container = scrollContainerRef.current;
if (!container) return;
if (container.scrollLeft !== lastScrollLeftRef.current) {
lastScrollLeftRef.current = container.scrollLeft;
setScrollCloseSignal((signal) => signal + 1);
}
}
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const totalContentWidth =
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
const skeletonContentWidth =
DOC_COL_W_PX + SKELETON_COLS * DATA_COL_W_PX + 32;
useImperativeHandle(ref, () => ({
scrollToCell(colIdx: number, rowIdx: number) {
const container = scrollContainerRef.current;
@ -269,12 +282,14 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{columns.map((col) => (
<div
key={col.index}
data-tr-col-header
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
>
<div className="flex items-center justify-between gap-3">
<span className="truncate">{col.name}</span>
<TREditColumnMenu
column={col}
closeSignal={scrollCloseSignal}
disabled={savingColumn || savingColumnsConfig}
onSave={onUpdateColumn}
onDelete={onDeleteColumn}
@ -298,7 +313,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
<div
className="flex flex-1 flex-col overflow-auto min-h-0"
ref={scrollContainerRef}
onScroll={syncHeader}
onScroll={handleRowsScroll}
>
<div className="relative min-h-0 flex-1">
{dragOverFiles && (
@ -379,6 +394,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
<TabularCellComponent
cell={cell}
column={col}
closeSignal={scrollCloseSignal}
onExpand={() => onExpand(cell)}
onCitationClick={(
page,

View file

@ -12,6 +12,7 @@ import { SkeletonLine } from "../shared/TablePrimitive";
interface Props {
cell: TCell;
column?: ColumnConfig;
closeSignal?: number;
onExpand: () => void;
onCitationClick?: (page: number, quote: string) => void;
}
@ -158,12 +159,19 @@ function CellMarkdown({
export function TabularCell({
cell,
column,
closeSignal,
onExpand,
onCitationClick,
}: Props) {
const [inlineExpanded, setInlineExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (closeSignal === undefined) return;
const timeout = window.setTimeout(() => setInlineExpanded(false), 0);
return () => window.clearTimeout(timeout);
}, [closeSignal]);
useEffect(() => {
if (!inlineExpanded) return;
function handleClickOutside(e: MouseEvent) {
@ -240,7 +248,7 @@ export function TabularCell({
{/* Inline expanded overlay — absolutely positioned so it overlays without disrupting table layout */}
{inlineExpanded && (
<div className="absolute left-0 top-0 z-50 w-full bg-white border border-gray-200 shadow-lg rounded-sm">
<div className="absolute left-0 top-0 z-50 w-full rounded-xl bg-gray-50/95 shadow-[0_4px_12px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl">
<div className="relative p-2 pr-4 text-xs text-gray-800 leading-relaxed">
{cell.content.flag && (
<span

View file

@ -0,0 +1,206 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import { ModalSelect } from "../modals/ModalSelect";
import { ModalTextInput } from "../modals/ModalTextInput";
import type { Project, TabularReview } from "../shared/types";
interface TabularReviewDetailsModalProps {
open: boolean;
review: TabularReview | null;
projects: Project[];
canEdit: boolean;
lockProject?: boolean;
onClose: () => void;
onSave: (values: {
title: string;
projectId?: string | null;
}) => Promise<void>;
}
export function TabularReviewDetailsModal({
open,
review,
projects,
canEdit,
lockProject = false,
onClose,
onSave,
}: TabularReviewDetailsModalProps) {
const [titleDraft, setTitleDraft] = useState("");
const [underProject, setUnderProject] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !review) return;
setTitleDraft(review.title ?? "");
setUnderProject(Boolean(review.project_id));
setSelectedProjectId(review.project_id ?? "");
setSaving(false);
setSaved(false);
setError(null);
}, [open, review]);
const trimmedTitle = titleDraft.trim();
const nextProjectId = underProject ? selectedProjectId : null;
const projectOptions = useMemo(
() =>
projects.length
? projects.map((project) => ({
value: project.id,
label:
project.name +
(project.cm_number ? ` (#${project.cm_number})` : ""),
}))
: [{ value: "", label: "No projects found" }],
[projects],
);
const hasChanges = useMemo(() => {
if (!review) return false;
return (
trimmedTitle !== (review.title ?? "") ||
nextProjectId !== (review.project_id ?? null)
);
}, [nextProjectId, review, trimmedTitle]);
if (!review) return null;
async function handleSave() {
if (
!canEdit ||
saving ||
!hasChanges ||
!trimmedTitle ||
(underProject && !selectedProjectId)
) {
return;
}
setSaving(true);
setSaved(false);
setError(null);
try {
await onSave({
title: trimmedTitle,
projectId: nextProjectId,
});
setSaved(true);
} catch {
setError("Could not update tabular review details.");
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={[
"Tabular Reviews",
review.title || "Untitled Review",
"Details",
]}
footerStatus={
error ? (
<span className="text-sm text-red-600">{error}</span>
) : saved ? (
<span className="text-sm text-gray-400">Updated</span>
) : null
}
primaryAction={
canEdit
? {
label: saving ? "Saving..." : "Save changes",
onClick: () => void handleSave(),
disabled:
saving ||
!hasChanges ||
!trimmedTitle ||
(underProject && !selectedProjectId),
}
: undefined
}
cancelAction={canEdit ? undefined : false}
>
<div className="space-y-6">
<div>
<ModalFieldLabel htmlFor="tabular-review-details-title">
Review name
</ModalFieldLabel>
<ModalTextInput
id="tabular-review-details-title"
type="text"
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
setSaved(false);
setError(null);
}}
placeholder="Review name"
variant="minimal"
className="placeholder:text-gray-400"
disabled={!canEdit || saving}
autoFocus
/>
</div>
{!lockProject && (
<div className="space-y-3">
<ModalFieldLabel as="p">Project</ModalFieldLabel>
<button
type="button"
onClick={() => {
if (!canEdit || saving) return;
const next = !underProject;
setUnderProject(next);
if (!next) setSelectedProjectId("");
setSaved(false);
setError(null);
}}
className="flex w-fit items-center gap-2.5"
>
<span
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${
underProject ? "bg-gray-900" : "bg-gray-100"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${
underProject
? "translate-x-4"
: "translate-x-0"
}`}
/>
</span>
<span className="text-sm text-gray-600">
Move under a project
</span>
</button>
{underProject && (
<ModalSelect
id="tabular-review-details-project"
value={selectedProjectId}
options={projectOptions}
onChange={(value) => {
setSelectedProjectId(value);
setSaved(false);
setError(null);
}}
placeholder="Select project..."
disabled={
!canEdit || saving || projects.length === 0
}
/>
)}
</div>
)}
</div>
</Modal>
);
}

View file

@ -46,8 +46,8 @@ import { OwnerOnlyPopup } from "../popups/OwnerOnlyPopup";
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
import { ConfirmPopup } from "../popups/ConfirmPopup";
import { HeaderActionsMenu } from "../shared/HeaderActionsMenu";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
getModelProvider,
isModelAvailable,

View file

@ -2,16 +2,16 @@ import type { LucideIcon } from "lucide-react";
import { AlignLeft, List, Hash, DollarSign, ToggleLeft, Calendar, Tag, Percent, Banknote } from "lucide-react";
import type { ColumnFormat } from "../shared/types";
export const FORMAT_OPTIONS: Array<{ value: ColumnFormat; label: string; icon: LucideIcon }> = [
{ value: "text", label: "Free Text", icon: AlignLeft },
{ value: "bulleted_list", label: "Bulleted list", icon: List },
{ value: "number", label: "Number", icon: Hash },
{ value: "percentage", label: "Percentage", icon: Percent },
{ value: "monetary_amount", label: "Monetary Amount", icon: Banknote },
{ value: "currency", label: "Currency", icon: DollarSign },
{ value: "yes_no", label: "Yes / No", icon: ToggleLeft },
{ value: "date", label: "Date", icon: Calendar },
{ value: "tag", label: "Tags", icon: Tag },
export const FORMAT_OPTIONS: Array<{ value: ColumnFormat; label: string; icon: LucideIcon; iconClassName: string }> = [
{ value: "text", label: "Free Text", icon: AlignLeft, iconClassName: "text-sky-500" },
{ value: "bulleted_list", label: "Bulleted list", icon: List, iconClassName: "text-indigo-500" },
{ value: "number", label: "Number", icon: Hash, iconClassName: "text-violet-500" },
{ value: "percentage", label: "Percentage", icon: Percent, iconClassName: "text-fuchsia-500" },
{ value: "monetary_amount", label: "Monetary Amount", icon: Banknote, iconClassName: "text-emerald-600" },
{ value: "currency", label: "Currency", icon: DollarSign, iconClassName: "text-teal-600" },
{ value: "yes_no", label: "Yes / No", icon: ToggleLeft, iconClassName: "text-amber-500" },
{ value: "date", label: "Date", icon: Calendar, iconClassName: "text-rose-500" },
{ value: "tag", label: "Tags", icon: Tag, iconClassName: "text-orange-500" },
];
export function formatLabel(format: ColumnFormat): string {
@ -21,3 +21,7 @@ export function formatLabel(format: ColumnFormat): string {
export function formatIcon(format: ColumnFormat): LucideIcon {
return FORMAT_OPTIONS.find((o) => o.value === format)?.icon ?? AlignLeft;
}
export function formatIconClassName(format: ColumnFormat): string {
return FORMAT_OPTIONS.find((o) => o.value === format)?.iconClassName ?? "text-sky-500";
}

View file

@ -2,7 +2,7 @@ import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { cn } from "@/app/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",

View file

@ -4,7 +4,7 @@ import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
function DropdownMenu({
...props

View file

@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cn } from "@/app/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (

View file

@ -2,7 +2,7 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
type PillButtonTone = "black" | "white" | "blue" | "danger";
type PillButtonSize = "sm" | "normal";
@ -15,7 +15,7 @@ type PillButtonProps = React.ComponentProps<"button"> & {
const toneClasses: Record<PillButtonTone, string> = {
black: "border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.12),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 disabled:hover:bg-gray-950/88",
white: "border-transparent bg-transparent text-gray-700 shadow-[0_1px_3px_rgba(0,0,0,0.22),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(255,255,255,0.7)] hover:bg-gray-100 disabled:hover:bg-transparent",
white: "border-transparent bg-transparent text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.10),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(255,255,255,0.7)] hover:bg-gray-100 disabled:hover:bg-transparent",
blue: "border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 disabled:hover:bg-blue-600/90",
danger: "border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.14),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 disabled:hover:bg-red-600/90",
};

View file

@ -0,0 +1,102 @@
"use client";
import * as React from "react";
import { Search, X } from "lucide-react";
import { cn } from "@/app/lib/utils";
type SearchBarSize = "sm" | "normal";
type SearchBarProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"onChange" | "size" | "type" | "value"
> & {
value: string;
onValueChange: (value: string) => void;
size?: SearchBarSize;
clearLabel?: string;
wrapperClassName?: string;
inputClassName?: string;
};
const sizeClasses: Record<
SearchBarSize,
{ wrapper: string; input: string; icon: string; clear: string }
> = {
sm: {
wrapper: "h-8 gap-1.5 rounded-lg px-2.5",
input: "text-xs",
icon: "h-3 w-3",
clear: "h-5 w-5",
},
normal: {
wrapper: "h-9 gap-2 rounded-xl px-3",
input: "text-sm",
icon: "h-3.5 w-3.5",
clear: "h-6 w-6",
},
};
export const SearchBar = React.forwardRef<HTMLInputElement, SearchBarProps>(
(
{
value,
onValueChange,
size = "normal",
clearLabel = "Clear search",
placeholder = "Search...",
className,
wrapperClassName,
inputClassName,
...props
},
ref,
) => {
const classes = sizeClasses[size];
return (
<div
className={cn(
"flex items-center border border-white/70 bg-white/55 text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors focus-within:border-white/90 focus-within:bg-white/70",
classes.wrapper,
className,
wrapperClassName,
)}
>
<Search
className={cn(
"shrink-0 text-gray-400",
classes.icon,
)}
/>
<input
ref={ref}
type="search"
value={value}
placeholder={placeholder}
onChange={(event) => onValueChange(event.target.value)}
className={cn(
"min-w-0 flex-1 bg-transparent text-gray-700 outline-none placeholder:text-gray-400 [&::-webkit-search-cancel-button]:hidden",
classes.input,
inputClassName,
)}
{...props}
/>
{value ? (
<button
type="button"
onClick={() => onValueChange("")}
className={cn(
"flex shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-white/70 hover:text-gray-600",
classes.clear,
)}
aria-label={clearLabel}
>
<X className={classes.icon} />
</button>
) : null}
</div>
);
},
);
SearchBar.displayName = "SearchBar";

View file

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { MessageSquare, Table2 } from "lucide-react";
import { MessageSquare, Table2, Upload } from "lucide-react";
import { createWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
import type { Workflow } from "../shared/types";
import { PRACTICE_OPTIONS } from "./practices";
@ -188,9 +188,15 @@ export function NewWorkflowModal({
>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [importedSkillMd, setImportedSkillMd] = useState("");
const [importedSkillName, setImportedSkillName] = useState<string | null>(
null,
);
const [markdownImportError, setMarkdownImportError] = useState("");
const customLanguageInputRef = useRef<HTMLInputElement>(null);
const customInputRef = useRef<HTMLInputElement>(null);
const customJurisdictionInputRef = useRef<HTMLInputElement>(null);
const markdownInputRef = useRef<HTMLInputElement>(null);
const isEditing = !!editWorkflow;
const isOtherLanguage = language === "Other";
@ -237,13 +243,20 @@ export function NewWorkflowModal({
setCustomJurisdiction("");
setOpenDropdown(null);
setError("");
setImportedSkillMd("");
setImportedSkillName(null);
setMarkdownImportError("");
if (markdownInputRef.current) {
markdownInputRef.current.value = "";
}
}, []);
useEffect(() => {
if (open && editWorkflow) {
setTitle(editWorkflow.title);
setType(editWorkflow.type);
const savedLanguage = editWorkflow.language ?? DEFAULT_LANGUAGE;
setTitle(editWorkflow.metadata.title);
setType(editWorkflow.metadata.type);
const savedLanguage =
editWorkflow.metadata.language ?? DEFAULT_LANGUAGE;
const isKnownLanguage = (LANGUAGE_OPTIONS as readonly string[]).includes(savedLanguage);
if (!isKnownLanguage && savedLanguage) {
setLanguage("Other");
@ -252,8 +265,8 @@ export function NewWorkflowModal({
setLanguage(savedLanguage);
setCustomLanguage("");
}
const savedJurisdiction = editWorkflow.jurisdictions?.length
? editWorkflow.jurisdictions.join(", ")
const savedJurisdiction = editWorkflow.metadata.jurisdictions?.length
? editWorkflow.metadata.jurisdictions.join(", ")
: DEFAULT_JURISDICTION;
const isKnownJurisdiction =
(JURISDICTION_OPTIONS as readonly string[]).includes(savedJurisdiction);
@ -282,7 +295,7 @@ export function NewWorkflowModal({
setJurisdictionRegion("");
setCustomJurisdiction("");
}
const saved = editWorkflow.practice ?? DEFAULT_PRACTICE;
const saved = editWorkflow.metadata.practice ?? DEFAULT_PRACTICE;
const isKnown = (PRACTICE_OPTIONS as readonly string[]).includes(saved);
if (!isKnown && saved) {
setPractice("Other");
@ -325,24 +338,32 @@ export function NewWorkflowModal({
try {
if (isEditing && editWorkflow) {
const updated = await updateWorkflow(editWorkflow.id, {
title: title.trim(),
language: effectiveLanguage || null,
practice: effectivePractice,
jurisdictions: effectiveJurisdictions.length
? effectiveJurisdictions
: null,
metadata: {
title: title.trim(),
language: effectiveLanguage || null,
practice: effectivePractice,
jurisdictions: effectiveJurisdictions.length
? effectiveJurisdictions
: null,
},
});
onUpdated?.(updated);
} else {
const workflow = await createWorkflow({
title: title.trim(),
type,
language: effectiveLanguage || null,
practice: effectivePractice,
jurisdictions: effectiveJurisdictions.length
? effectiveJurisdictions
: null,
});
const createPayload: Parameters<typeof createWorkflow>[0] = {
metadata: {
title: title.trim(),
type,
language: effectiveLanguage || null,
practice: effectivePractice,
jurisdictions: effectiveJurisdictions.length
? effectiveJurisdictions
: null,
},
};
if (type === "assistant" && importedSkillMd) {
createPayload.skill_md = importedSkillMd;
}
const workflow = await createWorkflow(createPayload);
onCreated(workflow);
}
resetForm();
@ -359,6 +380,37 @@ export function NewWorkflowModal({
onClose();
}
async function handleMarkdownImport(
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0];
setMarkdownImportError("");
if (!file) return;
const normalizedName = file.name.toLowerCase();
if (
!normalizedName.endsWith(".md") &&
!normalizedName.endsWith(".markdown")
) {
setImportedSkillMd("");
setImportedSkillName(null);
setMarkdownImportError("Choose a .md or .markdown file.");
e.target.value = "";
return;
}
try {
const text = await file.text();
setImportedSkillMd(text);
setImportedSkillName(file.name);
} catch {
setImportedSkillMd("");
setImportedSkillName(null);
setMarkdownImportError("Could not read that markdown file.");
e.target.value = "";
}
}
return (
<Modal
open={open}
@ -379,6 +431,16 @@ export function NewWorkflowModal({
form: formId,
disabled: !title.trim() || loading,
}}
secondaryAction={
!isEditing && type === "assistant"
? {
label: importedSkillName ?? "Upload markdown",
icon: <Upload className="h-3.5 w-3.5" />,
onClick: () => markdownInputRef.current?.click(),
disabled: loading,
}
: undefined
}
>
<form
id={formId}
@ -573,8 +635,19 @@ export function NewWorkflowModal({
)}
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
{(error || markdownImportError) && (
<p className="text-sm text-red-500">
{error || markdownImportError}
</p>
)}
</div>
<input
ref={markdownInputRef}
type="file"
className="hidden"
accept=".md,.markdown,text/markdown,text/x-markdown,text/plain"
onChange={handleMarkdownImport}
/>
</form>
</Modal>
);

View file

@ -0,0 +1,336 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, EyeOff, User } from "lucide-react";
import { openSourceWorkflow } from "@/app/lib/mikeApi";
import type { WorkflowOpenSourceSubmission } from "@/app/components/shared/types";
import { Modal } from "@/app/components/modals/Modal";
import { ModalFieldLabel } from "@/app/components/modals/ModalFieldLabel";
import { ModalSegmentedToggle } from "@/app/components/modals/ModalSegmentedToggle";
import { ModalTextInput } from "@/app/components/modals/ModalTextInput";
type OpenSourceContributorMode = "named" | "anonymous";
type OpenSourceStatus = "idle" | "loading" | "complete";
const WORKFLOWS_REPO_URL =
"https://github.com/Open-Legal-Products/mike-workflows";
interface OpenSourceWorkflowModalProps {
open: boolean;
onClose: () => void;
workflowId: string;
defaultContributorName: string;
pending: boolean;
onSubmitted: (submission: WorkflowOpenSourceSubmission) => void;
}
export function OpenSourceWorkflowModal({
open,
onClose,
workflowId,
defaultContributorName,
pending,
onSubmitted,
}: OpenSourceWorkflowModalProps) {
const [status, setStatus] = useState<OpenSourceStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [contributorMode, setContributorMode] =
useState<OpenSourceContributorMode>("anonymous");
const [contributorName, setContributorName] = useState("");
const [contributorOrganisation, setContributorOrganisation] = useState("");
const [contributorRole, setContributorRole] = useState("");
const [contributorLinkedin, setContributorLinkedin] = useState("");
const [disclosureConsent, setDisclosureConsent] = useState(false);
const [closeCountdown, setCloseCountdown] = useState(3);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
function resetModalState() {
setStatus("idle");
setError(null);
setContributorMode("anonymous");
setContributorName("");
setContributorOrganisation("");
setContributorRole("");
setContributorLinkedin("");
setDisclosureConsent(false);
setCloseCountdown(3);
}
useEffect(() => {
if (status !== "complete") return;
const countdownTimer = window.setInterval(() => {
setCloseCountdown((current) => Math.max(current - 1, 1));
}, 1000);
const closeTimer = window.setTimeout(() => {
onCloseRef.current();
resetModalState();
}, 3000);
return () => {
window.clearInterval(countdownTimer);
window.clearTimeout(closeTimer);
};
}, [status]);
const loading = status === "loading";
const submitted = status === "complete";
const needsConsent = contributorMode === "named" && !disclosureConsent;
async function handleSubmit() {
setStatus("loading");
setError(null);
try {
const response = await openSourceWorkflow(workflowId, {
contributor_mode: contributorMode,
contributor:
contributorMode === "named"
? {
name:
contributorName.trim() ||
defaultContributorName,
organisation:
contributorOrganisation.trim() || null,
role: contributorRole.trim() || null,
linkedin: contributorLinkedin.trim() || null,
}
: null,
});
onSubmitted({
id: response.id,
status: response.status,
submitted_at: response.submitted_at,
updated_at: response.updated_at,
reviewed_at: response.reviewed_at ?? null,
});
setCloseCountdown(3);
setStatus("complete");
} catch (err) {
setStatus("idle");
setError(
err instanceof Error
? err.message
: "Failed to submit workflow for review.",
);
}
}
return (
<Modal
open={open}
onClose={() => {
if (loading) return;
onClose();
resetModalState();
}}
breadcrumbs={[
"Workflows",
submitted
? "Submitted"
: pending
? "Update open-source submission"
: "Open source",
]}
primaryAction={
submitted
? undefined
: {
label: loading
? pending
? "Updating..."
: "Submitting..."
: pending
? "Update submission"
: "Submit for review",
onClick: () => void handleSubmit(),
disabled: loading || needsConsent,
}
}
>
{submitted ? (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center pb-10 text-center">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-full border border-emerald-200/70 bg-emerald-50/55 text-emerald-700 shadow-[0_10px_28px_rgba(16,185,129,0.16),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(16,185,129,0.12)] ring-1 ring-white/70 backdrop-blur-xl">
<Check className="h-6 w-6" />
</div>
<h3 className="text-2xl font-serif text-gray-950">
Workflow submitted
</h3>
<p className="mt-3 max-w-sm text-xs leading-6 text-gray-600">
Your workflow snapshot has been submitted for review.
You&apos;ll be notified by email if it is accepted.
</p>
<p className="mt-6 text-xs font-medium text-gray-500">
Closing in {closeCountdown}
</p>
</div>
) : (
<div className="space-y-4 pb-2 text-sm text-gray-700">
<h3 className="text-2xl font-serif text-gray-950">
Contribute to open source legal
</h3>
<p className="leading-6 text-xs text-gray-600">
Submit a snapshot of this workflow for review. If
accepted, it will be shared under the Apache License 2.0
in the{" "}
<a
href={WORKFLOWS_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-gray-950 underline decoration-gray-300 underline-offset-4 transition-colors hover:text-gray-600"
>
Open-Legal-Products/mike-workflows
</a>{" "}
repo. You&apos;ll be notified by email if your workflow
is accepted.
</p>
{pending && (
<p className="rounded-xl border border-white/70 bg-white/55 px-3 py-2 text-xs leading-5 text-gray-600 shadow-[0_8px_24px_rgba(15,23,42,0.07),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl">
You already have a pending submission. This will
replace that pending snapshot.
</p>
)}
<div className="space-y-2 pt-2">
<ModalFieldLabel as="p">
Contributor attribution
</ModalFieldLabel>
<ModalSegmentedToggle
value={contributorMode}
onChange={setContributorMode}
disabled={loading}
options={[
{
value: "anonymous",
label: "Anonymous",
icon: EyeOff,
},
{
value: "named",
label: "Disclose details",
icon: User,
},
]}
/>
{contributorMode === "named" && (
<div className="grid gap-x-4 gap-y-5 pt-4 sm:grid-cols-2">
<div>
<ModalFieldLabel htmlFor="open-source-contributor-name">
Full Name
</ModalFieldLabel>
<ModalTextInput
id="open-source-contributor-name"
value={contributorName}
onChange={(event) =>
setContributorName(
event.target.value,
)
}
placeholder="Jane Doe"
disabled={loading}
/>
</div>
<div>
<ModalFieldLabel htmlFor="open-source-contributor-organisation">
Organisation
</ModalFieldLabel>
<ModalTextInput
id="open-source-contributor-organisation"
value={contributorOrganisation}
onChange={(event) =>
setContributorOrganisation(
event.target.value,
)
}
placeholder="Acme LLP"
disabled={loading}
/>
</div>
<div>
<ModalFieldLabel htmlFor="open-source-contributor-role">
Role
</ModalFieldLabel>
<ModalTextInput
id="open-source-contributor-role"
value={contributorRole}
onChange={(event) =>
setContributorRole(
event.target.value,
)
}
placeholder="Senior Associate"
disabled={loading}
/>
</div>
<div>
<ModalFieldLabel htmlFor="open-source-contributor-linkedin">
LinkedIn
</ModalFieldLabel>
<ModalTextInput
id="open-source-contributor-linkedin"
type="url"
value={contributorLinkedin}
onChange={(event) =>
setContributorLinkedin(
event.target.value,
)
}
placeholder="https://linkedin.com/in/janedoe"
disabled={loading}
/>
</div>
<div className="flex items-start gap-2 pt-1 sm:col-span-2">
<input
id="open-source-disclosure-consent"
type="checkbox"
checked={disclosureConsent}
onChange={(event) =>
setDisclosureConsent(
event.target.checked,
)
}
disabled={loading}
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-black accent-black focus:ring-gray-400 disabled:cursor-not-allowed"
/>
<p className="text-xs leading-5 text-gray-600">
<label
htmlFor="open-source-disclosure-consent"
className="cursor-pointer"
>
I consent to disclosing the personal
information above in the public
</label>{" "}
<a
href={WORKFLOWS_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-gray-900 underline decoration-gray-300 underline-offset-4 transition-colors hover:text-gray-600"
>
Open-Legal-Products/mike-workflows
</a>{" "}
<label
htmlFor="open-source-disclosure-consent"
className="cursor-pointer"
>
GitHub repository and on the
mikeoss.com website.
</label>
</p>
</div>
</div>
)}
</div>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
{error}
</p>
)}
</div>
)}
</Modal>
);
}

View file

@ -0,0 +1,392 @@
"use client";
import { useEffect, useState } from "react";
import type { Document, Workflow } from "../shared/types";
import { createTabularReview } from "@/app/lib/mikeApi";
import { useRouter } from "next/navigation";
import { useDirectoryData } from "../shared/useDirectoryData";
import { FileDirectory } from "../shared/FileDirectory";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle";
import { ModalSelect } from "../modals/ModalSelect";
import { ModalTextarea } from "../modals/ModalTextarea";
import { WorkflowPickerContent } from "./WorkflowPickerContent";
import { workflowDetailPath } from "./workflowRoutes";
interface Props {
workflows: Workflow[];
workflow: Workflow | null;
onClose: () => void;
skipSelect?: boolean;
}
function SelectedWorkflowSummary({ workflow }: { workflow: Workflow }) {
return (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<span className="shrink-0 text-xs font-medium text-gray-700">
Selected workflow
</span>
<span className="min-w-0 flex-1 truncate text-right text-xs text-gray-500">
{workflow.metadata.title}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// UseWorkflowModal
// ---------------------------------------------------------------------------
export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = false }: Props) {
const [screen, setScreen] = useState<"select" | "details" | "documents">("select");
const [selected, setSelected] = useState<Workflow | null>(workflow);
const [listSearch, setListSearch] = useState("");
// Configure screen state
const [inProject, setInProject] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
new Set(),
);
const [assistantPrompt, setAssistantPrompt] = useState("");
const [saving, setSaving] = useState(false);
const router = useRouter();
const { saveChat, setNewChatMessages } = useChatHistoryContext();
const {
loading: dirLoading,
projects,
standaloneDocuments,
} = useDirectoryData(screen !== "select");
useEffect(() => {
if (workflow) {
setSelected(workflow);
setScreen(skipSelect ? "details" : "select");
setListSearch("");
} else {
setSelected(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflow?.id]);
// Reset configure state on back
useEffect(() => {
if (screen === "select") {
resetConfigureState();
}
}, [screen]);
function resetConfigureState() {
setInProject(false);
setSelectedProjectId(null);
setSelectedDocIds(new Set());
setAssistantPrompt("");
}
function handleClose() {
setSelected(null);
setScreen("select");
resetConfigureState();
onClose();
}
if (!workflow) return null;
const wf = selected ?? workflow;
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async function handleStartChat() {
setSaving(true);
try {
const projectId = inProject ? selectedProjectId! : undefined;
const chatId = await saveChat(projectId);
if (!chatId) return;
const allDocs: Document[] = [
...standaloneDocuments,
...projects.flatMap((p) => p.documents || []),
];
const files = allDocs
.filter((d) => selectedDocIds.has(d.id))
.map((d) => ({
filename: d.filename,
document_id: d.id,
}));
const content = assistantPrompt.trim()
? `implement workflow\n${assistantPrompt.trim()}`
: "implement workflow";
setNewChatMessages([
{
role: "user",
content,
files: files.length > 0 ? files : undefined,
workflow: { id: wf.id, title: wf.metadata.title },
},
]);
handleClose();
router.push(
projectId
? `/projects/${projectId}/assistant/chat/${chatId}`
: `/assistant/chat/${chatId}`,
);
} finally {
setSaving(false);
}
}
async function handleCreateReview() {
const allDocs: Document[] = [
...standaloneDocuments,
...projects.flatMap((p) => p.documents || []),
];
const docIds = allDocs
.filter((d) => selectedDocIds.has(d.id))
.map((d) => d.id);
const projectId = inProject ? selectedProjectId! : undefined;
setSaving(true);
try {
const review = await createTabularReview({
title: wf.metadata.title,
document_ids: docIds,
columns_config: wf.columns_config || [],
workflow_id: wf.is_system ? undefined : wf.id,
project_id: projectId,
});
handleClose();
router.push(
projectId
? `/projects/${projectId}/tabular-reviews/${review.id}`
: `/tabular-reviews/${review.id}`,
);
} finally {
setSaving(false);
}
}
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const projectDocs = selectedProject?.documents ?? [];
const projectOptions = projects.map((project) => ({
value: project.id,
label:
project.name +
(project.cm_number ? ` (#${project.cm_number})` : ""),
}));
const location = inProject ? "project" : "workspace";
const locationOptions =
wf.metadata.type === "assistant"
? [
{ value: "workspace" as const, label: "Assistant" },
{ value: "project" as const, label: "Project assistant" },
]
: [
{ value: "workspace" as const, label: "Tabular reviews" },
{
value: "project" as const,
label: "Project tabular reviews",
},
];
const breadcrumbs =
screen === "select"
? ["Workflows", "Select workflow"]
: [
<button
key="workflows"
type="button"
onClick={() => setScreen("select")}
className="transition-colors hover:text-gray-700"
>
Workflows
</button>,
wf.metadata.title,
wf.metadata.type === "assistant" ? "New Chat" : "New Review",
screen === "details" ? "Details" : "Attach Documents",
];
const selectPageAction = () => {
router.push(workflowDetailPath(wf));
handleClose();
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Modal
open={!!workflow}
onClose={handleClose}
size={screen === "select" ? "xl" : "lg"}
breadcrumbs={breadcrumbs}
secondaryAction={
screen === "select"
? {
label: "View Page",
onClick: selectPageAction,
}
: screen === "details"
? {
label: "Back",
onClick: () => setScreen("select"),
disabled: saving,
}
: {
label: "Back",
onClick: () => setScreen("details"),
disabled: saving,
}
}
footerStatus={
screen === "documents" && selectedDocIds.size > 0 ? (
<span className="text-xs text-gray-400">
{selectedDocIds.size} selected
</span>
) : null
}
primaryAction={
screen === "select"
? {
label: "Use",
onClick: () => setScreen("details"),
}
: screen === "details"
? {
label: "Next",
onClick: () => setScreen("documents"),
disabled:
saving || (inProject && !selectedProjectId),
}
: wf.metadata.type === "assistant"
? {
label: saving ? "Starting…" : "Start Chat",
onClick: handleStartChat,
disabled:
saving || (inProject && !selectedProjectId),
}
: {
label: saving ? "Creating…" : "Create Review",
onClick: handleCreateReview,
disabled:
saving ||
selectedDocIds.size === 0 ||
(inProject && !selectedProjectId),
}
}
cancelAction={false}
>
{/* ── SELECT SCREEN ── */}
{screen === "select" && (
<WorkflowPickerContent
workflows={workflows}
selected={wf}
onSelect={(next) => {
if (next) setSelected(next);
}}
search={listSearch}
onSearchChange={setListSearch}
workflowType="all"
previewMode="auto"
showTypeIcon
allowClearPreview={false}
/>
)}
{/* ── DETAILS SCREEN ── */}
{screen === "details" && (
<div className="flex min-h-0 flex-1 flex-col">
<SelectedWorkflowSummary workflow={wf} />
<div className="space-y-6">
<div>
<ModalFieldLabel as="p">Use in</ModalFieldLabel>
<ModalSegmentedToggle
value={location}
onChange={(value) => {
setInProject(value === "project");
setSelectedProjectId(null);
setSelectedDocIds(new Set());
}}
options={locationOptions}
/>
</div>
{inProject && (
<div>
<ModalFieldLabel htmlFor="workflow-project">
Project
</ModalFieldLabel>
<ModalSelect
id="workflow-project"
value={selectedProjectId ?? ""}
options={projectOptions}
onChange={(value) => {
setSelectedProjectId(value || null);
setSelectedDocIds(new Set());
}}
placeholder={
dirLoading
? "Loading projects..."
: projects.length
? "Select project..."
: "No projects found"
}
disabled={dirLoading || projects.length === 0}
/>
</div>
)}
{wf.metadata.type === "assistant" && (
<div>
<ModalFieldLabel htmlFor="workflow-additional-message">
Additional message
</ModalFieldLabel>
<ModalTextarea
id="workflow-additional-message"
value={assistantPrompt}
onChange={(e) =>
setAssistantPrompt(e.target.value)
}
placeholder="Add any additional instructions..."
rows={4}
/>
</div>
)}
</div>
</div>
)}
{/* ── DOCUMENTS SCREEN ── */}
{screen === "documents" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={
inProject ? projectDocs : standaloneDocuments
}
directoryProjects={
inProject ? [] : projects
}
loading={dirLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
allowMultiple
forceExpanded={inProject}
emptyMessage={
inProject
? "No documents in this project"
: "No documents yet"
}
searchable
searchAutoFocus
showProjectTabs={!inProject}
/>
</div>
</div>
)}
</Modal>
);
}

View file

@ -4,7 +4,11 @@ import { createElement } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ColumnConfig } from "../shared/types";
import { formatIcon, formatLabel } from "../tabular/columnFormat";
import {
formatIcon,
formatIconClassName,
formatLabel,
} from "../tabular/columnFormat";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
@ -15,7 +19,7 @@ interface Props {
export function WFColumnViewModal({ col, onClose }: Props) {
const formatIconElement = createElement(formatIcon(col.format ?? "text"), {
className: "h-3.5 w-3.5 text-gray-400",
className: `h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`,
});
return (
<Modal

View file

@ -1,21 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Plus, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "../tabular/columnFormat";
import { FORMAT_OPTIONS } from "../tabular/columnFormat";
import { TAG_COLORS } from "../tabular/pillUtils";
import { getPresetConfig, PROMPT_PRESETS } from "../tabular/columnPresets";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ModalSelect } from "../modals/ModalSelect";
import { ModalTextarea } from "../modals/ModalTextarea";
import { ModalTextInput } from "../modals/ModalTextInput";
interface ColumnDraft {
name: string;
@ -33,6 +29,7 @@ interface Props {
}
export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props) {
const formId = "workflow-edit-column-modal-form";
const [draft, setDraft] = useState<ColumnDraft>({
name: column.name,
prompt: column.prompt,
@ -115,34 +112,40 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
});
}
const FormatIcon = formatIcon(draft.format);
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-2">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Workflows</span>
<span></span>
<span>Edit column</span>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100/70 hover:text-gray-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
{/* Body */}
<div className="px-6 pt-3 pb-5 overflow-y-auto flex-1">
return (
<Modal
open
onClose={onClose}
breadcrumbs={["Workflows", "Edit column"]}
primaryAction={{
label: "Save changes",
type: "submit",
form: formId,
disabled: !draft.name.trim() || !draft.prompt.trim(),
}}
cancelAction={{ label: "Cancel", onClick: onClose }}
secondaryAction={{
label: "Delete",
variant: "danger",
onClick: onDelete,
}}
>
<form
id={formId}
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto px-1 pb-5 pt-2">
<ModalFieldLabel htmlFor="workflow-column-name">
Column title
</ModalFieldLabel>
{/* Name row */}
<div className="flex items-start gap-2">
<div className="relative flex flex-1 items-start" ref={presetsRef}>
<input
<ModalTextInput
id="workflow-column-name"
type="text"
variant="minimal"
value={draft.name}
onChange={(e) => {
const name = e.target.value;
@ -158,7 +161,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
});
}}
placeholder="Column name"
className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
className="flex-1"
autoFocus
/>
<button
@ -198,37 +201,34 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
{/* Format */}
<div className="mt-4">
<ModalFieldLabel className="text-gray-500">Format</ModalFieldLabel>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none">
<span className="flex items-center gap-2">
<FormatIcon className="h-3.5 w-3.5 text-gray-400" />
{formatLabel(draft.format)}
</span>
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[200]">
<DropdownMenuRadioGroup
value={draft.format}
onValueChange={(v) => update({ format: v as ColumnFormat, tags: [], tagInput: "" })}
>
{FORMAT_OPTIONS.map((o) => (
<DropdownMenuRadioItem key={o.value} value={o.value}>
<o.icon className="h-3.5 w-3.5 text-gray-400" />
{o.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ModalFieldLabel htmlFor="workflow-column-format">
Format
</ModalFieldLabel>
<ModalSelect
id="workflow-column-format"
value={draft.format}
options={FORMAT_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
icon: option.icon,
iconClassName: option.iconClassName,
}))}
onChange={(value) =>
update({
format: value as ColumnFormat,
tags: [],
tagInput: "",
})
}
/>
</div>
{/* Tag input */}
{draft.format === "tag" && (
<div className="mt-3">
<ModalFieldLabel className="text-gray-500">Tags</ModalFieldLabel>
<ModalFieldLabel htmlFor="workflow-column-tag">
Tags
</ModalFieldLabel>
<div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400">
{draft.tags.map((tag, tagIdx) => (
<span
@ -245,14 +245,16 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
</button>
</span>
))}
<input
<ModalTextInput
id="workflow-column-tag"
type="text"
variant="minimal"
value={draft.tagInput}
onChange={(e) => update({ tagInput: e.target.value })}
onKeyDown={handleTagKeyDown}
onBlur={commitTag}
placeholder="Add tag…"
className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none"
className="min-w-[80px] flex-1 font-sans text-sm text-gray-700 placeholder:text-gray-400"
/>
</div>
<p className="mt-1 text-xs text-gray-400">Press Enter or comma to add a tag.</p>
@ -261,7 +263,12 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
{/* Prompt */}
<div className="mt-4 flex items-center justify-between">
<ModalFieldLabel className="mb-0 text-gray-500">Prompt</ModalFieldLabel>
<ModalFieldLabel
htmlFor="workflow-column-prompt"
className="mb-0"
>
Prompt
</ModalFieldLabel>
<button
type="button"
onClick={autoGeneratePrompt}
@ -276,44 +283,16 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
Auto-Generate Prompt
</button>
</div>
<textarea
<ModalTextarea
id="workflow-column-prompt"
rows={6}
value={draft.prompt}
onChange={(e) => update({ prompt: e.target.value })}
placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…"
className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed"
className="mt-2 min-h-36"
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<button
type="button"
onClick={onDelete}
className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors"
>
Delete
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100/70 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!draft.name.trim() || !draft.prompt.trim()}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
Save changes
</button>
</div>
</div>
</form>
</div>
</div>,
document.body,
</div>
</form>
</Modal>
);
}

View file

@ -6,8 +6,10 @@ import dynamic from "next/dynamic";
import {
Check,
ChevronDown,
Download,
Globe,
Pencil,
Play,
Plus,
Trash2,
Users,
@ -31,15 +33,23 @@ import type {
ColumnConfig,
Workflow,
} from "@/app/components/shared/types";
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
import {
formatIcon,
formatIconClassName,
formatLabel,
} from "@/app/components/tabular/columnFormat";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
import {
HeaderActionsMenu,
type HeaderActionsMenuItem,
} from "@/app/components/shared/HeaderActionsMenu";
import { PeopleModal } from "@/app/components/modals/PeopleModal";
import { OpenSourceWorkflowModal } from "@/app/components/workflows/OpenSourceWorkflowModal";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { NewWorkflowModal } from "@/app/components/workflows/NewWorkflowModal";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { downloadWorkflowZip } from "./workflowZipExport";
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
const WorkflowPromptEditor = dynamic(
() =>
@ -51,7 +61,7 @@ const WorkflowPromptEditor = dynamic(
interface Props {
id: string;
workflowType: Workflow["type"];
workflowType: Workflow["metadata"]["type"];
}
type SaveStatus = "idle" | "saving" | "saved";
@ -59,6 +69,7 @@ type DeleteStatus = "idle" | "loading" | "complete";
type WorkflowShare = Awaited<ReturnType<typeof listWorkflowShares>>[number];
const NAME_COL_W = "w-[332px] shrink-0";
const CHECKBOX_GUTTER = "h-2.5 w-2.5 shrink-0";
const WORKFLOW_CONTRIBUTIONS_ENABLED =
process.env.NEXT_PUBLIC_WORKFLOW_CONTRIBUTIONS_ENABLED === "true";
@ -129,12 +140,12 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
useEffect(() => {
getWorkflow(id)
.then((wf) => {
if (wf.type !== workflowType) {
if (wf.metadata.type !== workflowType) {
setNotFound(true);
return;
}
setWorkflow(wf);
setPromptMd(wf.prompt_md ?? "");
setPromptMd(wf.skill_md ?? "");
setColumns(
(wf.columns_config ?? [])
.slice()
@ -231,7 +242,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
setSaveStatus("saving");
debounceRef.current = setTimeout(async () => {
try {
await updateWorkflow(id, { prompt_md: newPromptMd });
await updateWorkflow(id, { skill_md: newPromptMd });
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
@ -342,6 +353,40 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
profile?.displayName?.trim() || user?.email || "your account name";
const openSourcePending =
workflow.open_source_submission?.status === "pending";
const workflowActionItems: HeaderActionsMenuItem[] = [
{
label: "Download workflow",
icon: Download,
onSelect: () => downloadWorkflowZip(workflow, promptMd, columns),
},
];
if (!readOnly) {
workflowActionItems.push({
label: "Edit details",
icon: Pencil,
onSelect: () => setDetailsOpen(true),
});
if (canOpenSource) {
workflowActionItems.push({
label: "Open source this",
icon: Globe,
onSelect: () => setOpenSourceOpen(true),
});
}
workflowActionItems.push({
label: "Delete",
icon: Trash2,
variant: "danger",
disabled: workflow.is_owner === false,
onSelect: () => {
setDeleteStatus("idle");
setDeleteOpen(true);
},
});
}
return (
<div className="flex flex-col h-full">
@ -357,81 +402,55 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{
label: (
<span className="text-gray-900 truncate max-w-xs">
{workflow.title}
{workflow.metadata.title}
</span>
),
},
]}
actions={[
{
label: "Use",
onClick: () => setUseOpen(true),
},
actionGroups={[
saveStatus !== "idle"
? {
type: "custom",
render: (
<span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500">
{saveStatus === "saved" ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : null}
{saveStatus === "saving"
? "Saving…"
: "Saved"}
</span>
),
}
: null,
canShare
? {
onClick: () => setShareOpen(true),
title: "Open workflow people",
iconOnly: true,
icon: <Users className="h-4 w-4" />,
}
: null,
!readOnly
? {
type: "custom",
render: (
<HeaderActionsMenu
title="Workflow actions"
items={[
{
label: "Edit details",
icon: Pencil,
onSelect: () =>
setDetailsOpen(true),
},
...(canOpenSource
? [
{
label:
"Open source this",
icon: Globe,
onSelect: () =>
setOpenSourceOpen(
true,
),
},
]
: []),
{
label: "Delete",
icon: Trash2,
variant: "danger",
disabled:
workflow.is_owner === false,
onSelect: () => {
setDeleteStatus("idle");
setDeleteOpen(true);
},
},
]}
/>
),
}
: null,
? [
{
type: "custom",
render: (
<span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500">
{saveStatus === "saved" ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : null}
{saveStatus === "saving"
? "Saving…"
: "Saved"}
</span>
),
},
]
: [],
[
canShare
? {
onClick: () => setShareOpen(true),
title: "Open workflow people",
iconOnly: true,
icon: <Users className="h-4 w-4" />,
}
: null,
{
type: "custom",
render: (
<HeaderActionsMenu
title="Workflow actions"
items={workflowActionItems}
/>
),
},
],
[
{
label: "Use",
icon: <Play className="h-3.5 w-3.5" />,
onClick: () => setUseOpen(true),
},
],
]}
/>
<UseWorkflowModal
@ -474,7 +493,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Workflows",
workflow.title,
workflow.metadata.title,
"People",
]}
onSharedWithChange={handleWorkflowSharedWithChange}
@ -516,9 +535,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{/* Metadata */}
<WorkflowMetadata workflow={workflow} />
{workflow.type === "assistant" ? (
{workflow.metadata.type === "assistant" ? (
/* ── Assistant: WYSIWYG editor ── */
<div className="flex-1 min-h-0 px-4 pb-2 pt-0 md:px-10 md:pb-3">
<div className="flex-1 min-h-0 px-4 pb-2 pt-4 md:px-10 md:pb-3">
<WorkflowPromptEditor
value={promptMd}
onChange={readOnly ? undefined : handlePromptChange}
@ -527,17 +546,10 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
</div>
) : (
/* ── Tabular: Column table ── */
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col flex-1 min-h-0 pt-2">
{/* Toolbar */}
{!readOnly && (
<div className="flex items-center justify-between px-4 md:px-10 h-10 border-b border-gray-200 shrink-0">
<button
onClick={() => setAddColumnOpen(true)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Add Column
</button>
<div className="flex items-center justify-between h-10 shrink-0 border-b border-gray-200 px-4 md:px-10">
{selectedColIndices.length > 0 && (
<div ref={colActionsRef} className="relative">
<button
@ -567,6 +579,16 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
)}
</div>
)}
{selectedColIndices.length === 0 && (
<span aria-hidden="true" />
)}
<button
onClick={() => setAddColumnOpen(true)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Add Column
</button>
</div>
)}
{readOnly && (
@ -582,13 +604,18 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{/* Table header */}
<div className={`flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none ${readOnly ? "border-t" : ""}`}>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
{columns.length > 0 && (
{columns.length > 0 ? (
<input
type="checkbox"
checked={columns.length > 0 && selectedColIndices.length === columns.length}
ref={(el) => { if (el) el.indeterminate = selectedColIndices.length > 0 && selectedColIndices.length < columns.length; }}
onChange={() => setSelectedColIndices(selectedColIndices.length === columns.length ? [] : columns.map((c) => c.index))}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`}
/>
) : (
<span
className={CHECKBOX_GUTTER}
aria-hidden="true"
/>
)}
<span>Column Title</span>
@ -635,7 +662,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
checked={isChecked}
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
onClick={(e) => e.stopPropagation()}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`}
/>
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{col.name}
@ -644,7 +671,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
</div>
<div className="ml-auto w-36 shrink-0">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<FormatIcon className="h-3.5 w-3.5 text-gray-400" />
<FormatIcon
className={`h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`}
/>
{formatLabel(col.format ?? "text")}
</span>
</div>
@ -716,14 +745,14 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
function WorkflowMetadata({ workflow }: { workflow: Workflow }) {
const fields: { label: string; value: string }[] = [
{ label: "Type", value: workflow.type === "tabular" ? "Tabular" : "Assistant" },
{ label: "Type", value: workflow.metadata.type === "tabular" ? "Tabular" : "Assistant" },
{ label: "Source", value: getWorkflowSourceLabel(workflow) },
];
if (workflow.language) fields.push({ label: "Language", value: workflow.language });
if (workflow.version) fields.push({ label: "Version", value: workflow.version });
if (workflow.practice) fields.push({ label: "Practice", value: workflow.practice });
if (workflow.jurisdictions?.length) {
fields.push({ label: "Jurisdiction", value: workflow.jurisdictions.join(", ") });
if (workflow.metadata.language) fields.push({ label: "Language", value: workflow.metadata.language });
if (workflow.metadata.version) fields.push({ label: "Version", value: workflow.metadata.version });
if (workflow.metadata.practice) fields.push({ label: "Practice", value: workflow.metadata.practice });
if (workflow.metadata.jurisdictions?.length) {
fields.push({ label: "Jurisdiction", value: workflow.metadata.jurisdictions.join(", ") });
}
if (workflow.open_source_submission) {
const statusLabels: Record<
@ -741,7 +770,7 @@ function WorkflowMetadata({ workflow }: { workflow: Workflow }) {
}
return (
<div className="flex flex-wrap gap-x-8 gap-y-3 px-4 py-3 text-xs shrink-0 md:px-10">
<div className="flex flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 text-xs shrink-0 md:px-10">
{fields.map(({ label, value }) => (
<div key={label} className="flex flex-col gap-0.5">
<span className="text-gray-400">{label}</span>
@ -762,14 +791,14 @@ function WorkflowMetadataSkeleton() {
];
return (
<div className="flex shrink-0 flex-wrap gap-x-8 gap-y-3 px-4 py-3 md:px-10">
<div className="flex shrink-0 flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 md:px-10">
{fields.map((field, index) => (
<div key={index} className="flex flex-col gap-1">
<div key={index} className="flex flex-col gap-0.5">
<div
className={`h-2.5 ${field.labelWidth} animate-pulse rounded bg-gray-100`}
className={`h-4 ${field.labelWidth} animate-pulse rounded bg-gray-100`}
/>
<div
className={`h-3 ${field.valueWidth} animate-pulse rounded bg-gray-100`}
className={`h-4 ${field.valueWidth} animate-pulse rounded bg-gray-100`}
/>
</div>
))}
@ -787,7 +816,7 @@ function getWorkflowSourceLabel(workflow: Workflow) {
function AssistantWorkflowEditorSkeleton() {
return (
<div className="min-h-0 flex-1 px-4 pb-2 pt-0 md:px-10 md:pb-3">
<div className="min-h-0 flex-1 px-4 pb-2 pt-4 md:px-10 md:pb-3">
<div className="h-full rounded-md border border-gray-200 bg-gray-50 px-5 py-4">
<div className="space-y-3">
<div className="h-3 w-24 animate-pulse rounded bg-gray-100" />
@ -813,8 +842,8 @@ function AssistantWorkflowEditorSkeleton() {
function TabularWorkflowEditorSkeleton() {
return (
<>
<div className="flex h-10 shrink-0 items-center border-b border-gray-200 px-4 md:px-10">
<div className="flex min-h-0 flex-1 flex-col pt-2">
<div className="flex h-10 shrink-0 items-center justify-end border-b border-gray-200 px-4 md:px-10">
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
</div>
@ -822,7 +851,9 @@ function TabularWorkflowEditorSkeleton() {
<div
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}
>
<div className="h-2.5 w-2.5 animate-pulse rounded bg-gray-100" />
<div
className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`}
/>
<div className="h-2.5 w-20 animate-pulse rounded bg-gray-100" />
</div>
<div className="w-36 shrink-0">
@ -843,7 +874,9 @@ function TabularWorkflowEditorSkeleton() {
<div
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}
>
<div className="h-2.5 w-2.5 shrink-0 animate-pulse rounded bg-gray-100" />
<div
className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`}
/>
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${40 + (i * 13) % 35}%` }}
@ -862,6 +895,6 @@ function TabularWorkflowEditorSkeleton() {
</div>
))}
</div>
</>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more