diff --git a/.env.example b/.env.example index afc3508e6..397f821ae 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,15 @@ S3_ENDPOINT="http://localhost:8333" S3_BUCKET="reactive-resume" S3_FORCE_PATH_STYLE="true" +# --- AI Agent Workspace (optional) --- +# Required only for the authenticated /agent workspace and saved AI providers. +REDIS_URL="redis://localhost:6379" +ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production" + +# Optional Cloudflare Browser Run credentials used as a URL extraction fallback for agent web access. +CLOUDFLARE_ACCOUNT_ID="" +CLOUDFLARE_API_TOKEN="" + # --- Feature Flags --- # This flag disables new signups, both on the web app and the server. FLAG_DISABLE_SIGNUPS="false" diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..b68e244bd --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,347 @@ +--- +version: alpha +name: Reactive Resume +description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support. +colors: + primary: "#343434" + primary-foreground: "#FBFBFB" + secondary: "#F7F7F7" + secondary-foreground: "#343434" + background: "#FFFFFF" + foreground: "#252525" + muted: "#F7F7F7" + muted-foreground: "#8E8E8E" + card: "#FFFFFF" + card-foreground: "#252525" + border: "#EBEBEB" + input: "#EBEBEB" + ring: "#B5B5B5" + destructive: "#DC2626" + on-destructive: "#FFFFFF" +typography: + heading: + fontFamily: IBM Plex Sans Variable + fontSize: 1rem + fontWeight: 500 + body: + fontFamily: IBM Plex Sans Variable + fontSize: 0.875rem + fontWeight: 400 + body-sm: + fontFamily: IBM Plex Sans Variable + fontSize: 0.75rem + fontWeight: 400 + label: + fontFamily: IBM Plex Sans Variable + fontSize: 0.8rem + fontWeight: 500 + hero-heading: + fontFamily: IBM Plex Sans Variable + fontSize: 3.75rem + fontWeight: 700 + letterSpacing: -0.025em +rounded: + sm: 0.18rem + md: 0.24rem + lg: 0.3rem + xl: 0.42rem + 2xl: 0.54rem + 3xl: 0.66rem + 4xl: 0.78rem +spacing: + xs: 4px + sm: 8px + md: 16px + lg: 24px + xl: 32px + 2xl: 48px +components: + button-default: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + rounded: "{rounded.lg}" + padding: 10px + height: 36px + button-outline: + backgroundColor: "{colors.background}" + textColor: "{colors.foreground}" + rounded: "{rounded.lg}" + padding: 10px + height: 36px + button-secondary: + backgroundColor: "{colors.secondary}" + textColor: "{colors.secondary-foreground}" + rounded: "{rounded.lg}" + padding: 10px + height: 36px + button-ghost: + backgroundColor: "{colors.background}" + textColor: "{colors.foreground}" + rounded: "{rounded.lg}" + padding: 10px + height: 36px + button-destructive: + backgroundColor: "{colors.destructive}" + textColor: "{colors.on-destructive}" + rounded: "{rounded.lg}" + padding: 10px + height: 36px + card: + backgroundColor: "{colors.card}" + textColor: "{colors.card-foreground}" + rounded: "{rounded.lg}" + padding: 16px + input: + backgroundColor: "{colors.background}" + textColor: "{colors.foreground}" + rounded: "{rounded.lg}" + height: 36px + padding: 10px + input-focus: + backgroundColor: "{colors.background}" + textColor: "{colors.foreground}" + rounded: "{rounded.lg}" + height: 36px + padding: 10px + badge: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + rounded: "{rounded.md}" + padding: 4px + popover: + backgroundColor: "{colors.card}" + textColor: "{colors.card-foreground}" + rounded: "{rounded.xl}" + padding: 4px + sidebar: + backgroundColor: "{colors.muted}" + textColor: "{colors.foreground}" + padding: 8px + sidebar-item: + backgroundColor: "{colors.muted}" + textColor: "{colors.muted-foreground}" + rounded: "{rounded.lg}" + padding: 8px + sidebar-item-active: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + rounded: "{rounded.lg}" + padding: 8px + tooltip: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + rounded: "{rounded.md}" + padding: 6px + separator: + backgroundColor: "{colors.border}" + height: 1px + dialog: + backgroundColor: "{colors.card}" + textColor: "{colors.card-foreground}" + rounded: "{rounded.xl}" + padding: 24px + input-invalid: + backgroundColor: "{colors.background}" + textColor: "{colors.destructive}" + rounded: "{rounded.lg}" + height: 36px + padding: 10px +--- + +## Overview + +Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it. + +The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page. + +The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site. + +One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic"). + +## Colors + +The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable. + +- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent. +- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes. +- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode. +- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth. +- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs. +- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually. +- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color. +- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable. +- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning. +- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements. +- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization. + +Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette. + +## Typography + +The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs. + +- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints. +- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting. +- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text. +- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata. +- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity. + +The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks. + +Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers. + +## Layout + +### Builder (Three-Panel Workspace) + +The core builder uses a resizable three-panel layout powered by `react-resizable-panels`: + +- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups. +- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page. +- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop). + +Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail). + +### Dashboard + +Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards. + +### Landing Page + +Full-width single-column marketing layout: +1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression. +2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon). +3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines. +4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names. +5. **Testimonials** — Tiled user quotes in a masonry-style grid. +6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info. + +### Responsive Breakpoints + +Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays. + +### Page Aspect Ratio + +A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export). + +## Animation + +Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern: + +**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety. + +**Timing principles:** +- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance. +- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger. +- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations. +- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements. + +**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press. + +**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s). + +**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference. + +## Elevation & Depth + +Elevation is handled through background color layering rather than drop shadows: + +- **Level 0 — Background:** The base canvas (`--background`). +- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards. +- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette. +- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace. + +The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear. + +## Shapes + +Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`: + +| Token | Value | Usage | +|:------|:------|:------| +| `sm` | 0.18rem (≈3px) | Small badges, inline chips | +| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements | +| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs | +| `xl` | 0.42rem (≈7px) | Larger cards, modal corners | +| `2xl` | 0.54rem (≈9px) | Dialog containers | +| `3xl` | 0.66rem (≈11px) | Large panels | +| `4xl` | 0.78rem (≈12px) | Full-page modals | + +The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default. + +## Components + +### Buttons + +Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup): + +- **Default:** Solid primary background. The highest-emphasis action on any screen. +- **Outline:** Transparent with a border. For secondary actions that need clear boundaries. +- **Secondary:** Muted background. For paired actions alongside a primary button. +- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise. +- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish. +- **Link:** Underline-on-hover text. For inline navigation within prose. + +Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons. + +### Cards + +White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`). + +### Forms + +Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring. + +### Dialogs + +Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows. + +### Command Palette + +Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings. + +### Toast Notifications + +Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete. + +### Drag and Drop + +Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library. + +## Internationalization + +The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions: + +**Direction:** The `` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute. + +**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets. + +**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers. + +**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror. + +**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, ``) — never hardcode English text in components. Translation files are `.po` format under `/locale/`. + +## Do's and Don'ts + +### Do + +- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful. +- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions. +- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped. +- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful. +- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active. +- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative. +- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers. +- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band. + +### Don't + +- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used. +- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow. +- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down. +- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic. +- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically. +- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family. +- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library. +- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`). diff --git a/Dockerfile.dev b/Dockerfile.dev index 43bcc8241..d9b3e696e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -39,4 +39,4 @@ COPY . . EXPOSE 3000/tcp -CMD ["pnpm", "dev"] +CMD ["pnpm", "run", "dev:web"] diff --git a/apps/web/package.json b/apps/web/package.json index fc2f8ae5d..b5411c868 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,7 +16,7 @@ "lingui:extract": "lingui extract --clean --overwrite" }, "dependencies": { - "@ai-sdk/react": "^3.0.182", + "@ai-sdk/react": "^3.0.184", "@base-ui/react": "^1.4.1", "@better-auth/api-key": "^1.6.11", "@better-auth/infra": "^0.2.8", @@ -54,17 +54,18 @@ "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-ssr-query": "^1.166.12", "@tanstack/react-start": "^1.167.65", - "@tiptap/extension-color": "^3.23.2", - "@tiptap/extension-highlight": "^3.23.2", - "@tiptap/extension-table": "^3.23.2", - "@tiptap/extension-text-align": "^3.23.2", - "@tiptap/extension-text-style": "^3.23.2", - "@tiptap/pm": "^3.23.2", - "@tiptap/react": "^3.23.2", - "@tiptap/starter-kit": "^3.23.2", + "@tiptap/extension-color": "^3.23.4", + "@tiptap/extension-highlight": "^3.23.4", + "@tiptap/extension-table": "^3.23.4", + "@tiptap/extension-text-align": "^3.23.4", + "@tiptap/extension-text-style": "^3.23.4", + "@tiptap/pm": "^3.23.4", + "@tiptap/react": "^3.23.4", + "@tiptap/starter-kit": "^3.23.4", "@types/js-cookie": "^3.0.6", "@uiw/color-convert": "^2.10.1", "@uiw/react-color-colorful": "^2.10.1", + "ai": "^6.0.182", "better-auth": "1.6.11", "cmdk": "^1.1.1", "drizzle-orm": "1.0.0-rc.2", @@ -78,6 +79,7 @@ "qrcode.react": "^4.2.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.1", "react-window": "^2.2.7", "react-zoom-pan-pinch": "^4.0.3", diff --git a/apps/web/src/components/command-palette/pages/navigation.tsx b/apps/web/src/components/command-palette/pages/navigation.tsx index f775f13dc..c31b3b04b 100644 --- a/apps/web/src/components/command-palette/pages/navigation.tsx +++ b/apps/web/src/components/command-palette/pages/navigation.tsx @@ -1,6 +1,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { + ChatCircleDotsIcon, GearIcon, HouseSimpleIcon, KeyIcon, @@ -44,6 +45,16 @@ export function NavigationCommandGroup() { Resumes + onNavigate("/agent")} + > + + Agent + + state.patchResume); } -export function useReplaceResumeFromServer() { - const queryClient = useQueryClient(); - const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer); - - return useCallback( - (resume: Resume) => { - bindRuntimeQueryClient(resume.id, queryClient); - queryClient.setQueryData(getResumeQueryKey(resume.id), resume); - replaceResumeFromServer(resume); - }, - [queryClient, replaceResumeFromServer], - ); -} - function useBuilderResumeSelector(selector: (resume: Resume) => T): T | undefined { const params = useParams({ strict: false }) as { resumeId?: string }; const resumeId = params.resumeId; diff --git a/apps/web/src/components/resume/preview.browser.test.tsx b/apps/web/src/components/resume/preview.browser.test.tsx index d2d87eed5..05c433b7b 100644 --- a/apps/web/src/components/resume/preview.browser.test.tsx +++ b/apps/web/src/components/resume/preview.browser.test.tsx @@ -93,20 +93,14 @@ describe("ResumePreviewClient", () => { previewMock.builderResumeData = resumeDataWithPageCount(3); previewMock.toBlob.mockImplementation(() => new Promise(() => {})); - render(); + render(); expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3); }); it("renders from explicit resume data when no builder resume is active", async () => { render( - , + , ); expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy(); diff --git a/apps/web/src/components/resume/preview.browser.tsx b/apps/web/src/components/resume/preview.browser.tsx index 64261d7dc..ec4a3cfa1 100644 --- a/apps/web/src/components/resume/preview.browser.tsx +++ b/apps/web/src/components/resume/preview.browser.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from "react"; import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared"; import { pdf } from "@react-pdf/renderer"; import { AnimatePresence, motion } from "motion/react"; @@ -6,7 +7,7 @@ import { cn } from "@reactive-resume/utils/style"; import { useLocalizedResumeDocument } from "@/libs/resume/pdf-document"; import { useResumeData } from "./builder-resume-draft"; import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas"; -import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared"; +import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared"; type PreviewPdf = { file: Blob; @@ -84,7 +85,7 @@ const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.fil export function ResumePreviewClient({ className, data, - pageGap, + pageGap = 16, pageLayout, pageScale, pageClassName, @@ -133,6 +134,7 @@ export function ResumePreviewClient({ if (!resumeData) return null; const visiblePdf = getActivePreviewLayer(previewLayers); + const resolvedPageGap = getResumePreviewGapValue(pageGap); if (!visiblePdf) { return ( @@ -154,8 +156,8 @@ export function ResumePreviewClient({ { it("applies the documented defaults when fields are omitted", () => { const result = normalizeResumePreviewProps({}); expect(result).toMatchObject({ - pageGap: 40, + pageGap: 16, pageLayout: "horizontal", pageScale: 1, showPageNumbers: false, @@ -52,6 +53,20 @@ describe("getScaledPreviewPageSize", () => { }); }); +describe("getResumePreviewGapValue", () => { + it("adds px units for numeric custom-property gap values", () => { + expect(getResumePreviewGapValue(96)).toBe("96px"); + }); + + it("preserves explicit zero gap", () => { + expect(getResumePreviewGapValue(0)).toBe(0); + }); + + it("preserves string gap values", () => { + expect(getResumePreviewGapValue("1rem")).toBe("1rem"); + }); +}); + const setDevicePixelRatio = (value: number) => { Object.defineProperty(window, "devicePixelRatio", { writable: true, diff --git a/apps/web/src/components/resume/preview.shared.test.tsx b/apps/web/src/components/resume/preview.shared.test.tsx index c59ac1909..00c18bf3a 100644 --- a/apps/web/src/components/resume/preview.shared.test.tsx +++ b/apps/web/src/components/resume/preview.shared.test.tsx @@ -29,6 +29,14 @@ describe("ResumePreviewLoader", () => { expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(pageCount); }); + + it("writes numeric page gaps as valid CSS custom-property lengths", () => { + const { container } = render(); + + expect((container.firstElementChild as HTMLElement).style.getPropertyValue("--resume-preview-page-gap")).toBe( + "96px", + ); + }); }); describe("getResumePreviewPageCount", () => { diff --git a/apps/web/src/components/resume/preview.shared.tsx b/apps/web/src/components/resume/preview.shared.tsx index 892b3f187..680037633 100644 --- a/apps/web/src/components/resume/preview.shared.tsx +++ b/apps/web/src/components/resume/preview.shared.tsx @@ -1,11 +1,12 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { CSSProperties } from "react"; import { Spinner } from "@reactive-resume/ui/components/spinner"; import { cn } from "@reactive-resume/utils/style"; export type ResumePreviewProps = { className?: string; data?: ResumeData; - pageGap?: React.CSSProperties["gap"]; + pageGap?: CSSProperties["gap"]; pageLayout?: "horizontal" | "vertical"; pageScale?: number; pageClassName?: string; @@ -13,7 +14,6 @@ export type ResumePreviewProps = { }; export type ResolvedResumePreviewProps = ResumePreviewProps & { - pageGap: React.CSSProperties["gap"]; pageLayout: "horizontal" | "vertical"; pageScale: number; showPageNumbers: boolean; @@ -26,7 +26,7 @@ export type PreviewPageSize = { type ResumePreviewLoaderProps = Pick & { pageCount?: number; - pageGap?: React.CSSProperties["gap"]; + pageGap?: CSSProperties["gap"]; pageLayout?: "horizontal" | "vertical"; pageScale?: number; }; @@ -39,7 +39,7 @@ export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = { }; export const normalizeResumePreviewProps = ({ - pageGap = 40, + pageGap = 16, pageLayout = "horizontal", pageScale = 1, showPageNumbers = false, @@ -67,25 +67,29 @@ export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: n width: pageSize.width * pageScale, }); +export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) => + typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap; + export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1); export function ResumePreviewLoader({ pageCount = 1, pageClassName, - pageGap = 40, + pageGap = 16, pageLayout = "horizontal", pageScale = 1, showPageNumbers = false, }: ResumePreviewLoaderProps) { const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale); + const resolvedPageGap = getResumePreviewGapValue(pageGap); return (
{Array.from({ length: pageCount }, (_, index) => { const pageNumber = index + 1; diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx index 640dc99f5..c39f28d92 100644 --- a/apps/web/src/dialogs/resume/import.tsx +++ b/apps/web/src/dialogs/resume/import.tsx @@ -4,12 +4,11 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react"; import { useStore } from "@tanstack/react-form"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import z from "zod"; -import { useAIStore } from "@reactive-resume/ai/store"; import { JSONResumeImporter } from "@reactive-resume/import/json-resume"; import { ReactiveResumeJSONImporter } from "@reactive-resume/import/reactive-resume-json"; import { ReactiveResumeV4JSONImporter } from "@reactive-resume/import/reactive-resume-v4-json"; @@ -91,7 +90,6 @@ function fileToBase64(file: File): Promise { export function ImportResumeDialog(_: DialogProps<"resume.import">) { const navigate = useNavigate(); - const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore(); const closeDialog = useDialogStore((state) => state.closeDialog); const prevTypeRef = useRef(""); @@ -99,6 +97,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { const [isLoading, setIsLoading] = useState(false); const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions()); + const { data: aiProviders, isLoading: isLoadingAiProviders } = useQuery(orpc.aiProviders.list.queryOptions()); + const hasAIProvider = aiProviders?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false; const form = useAppForm({ defaultValues: { @@ -137,23 +137,21 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { } if (value.type === "pdf") { - if (!isAIEnabled) - throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`); + if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`); + if (!hasAIProvider) + throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`); const base64 = await fileToBase64(value.file); data = await client.ai.parsePdf({ - provider, - model, - apiKey, - baseURL, file: { name: value.file.name, data: base64 }, }); } if (value.type === "docx") { - if (!isAIEnabled) - throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`); + if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`); + if (!hasAIProvider) + throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`); const base64 = await fileToBase64(value.file); @@ -163,10 +161,6 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { : ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const); data = await client.ai.parseDocx({ - provider, - model, - apiKey, - baseURL, mediaType, file: { name: value.file.name, data: base64 }, }); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 63d4e3725..af78714e1 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,10 +12,12 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SchemaDotjsonRouteImport } from './routes/schema[.]json' import { Route as DashboardRouteRouteImport } from './routes/dashboard/route' import { Route as AuthRouteRouteImport } from './routes/auth/route' +import { Route as AgentRouteRouteImport } from './routes/agent/route' import { Route as HomeRouteRouteImport } from './routes/_home/route' import { Route as McpIndexRouteImport } from './routes/mcp/index' import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' import { Route as AuthIndexRouteImport } from './routes/auth/index' +import { Route as AgentIndexRouteImport } from './routes/agent/index' import { Route as HomeIndexRouteImport } from './routes/_home/index' import { Route as TemplatesSplatRouteImport } from './routes/templates/$' import { Route as AuthVerify2faBackupRouteImport } from './routes/auth/verify-2fa-backup' @@ -27,6 +29,8 @@ import { Route as AuthOauthRouteImport } from './routes/auth/oauth' import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthForgotPasswordRouteImport } from './routes/auth/forgot-password' import { Route as ApiHealthRouteImport } from './routes/api/health' +import { Route as AgentNewRouteImport } from './routes/agent/new' +import { Route as AgentThreadIdRouteImport } from './routes/agent/$threadId' import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration' import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.]well-known/oauth-protected-resource' import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server' @@ -66,6 +70,11 @@ const AuthRouteRoute = AuthRouteRouteImport.update({ path: '/auth', getParentRoute: () => rootRouteImport, } as any) +const AgentRouteRoute = AgentRouteRouteImport.update({ + id: '/agent', + path: '/agent', + getParentRoute: () => rootRouteImport, +} as any) const HomeRouteRoute = HomeRouteRouteImport.update({ id: '/_home', getParentRoute: () => rootRouteImport, @@ -85,6 +94,11 @@ const AuthIndexRoute = AuthIndexRouteImport.update({ path: '/', getParentRoute: () => AuthRouteRoute, } as any) +const AgentIndexRoute = AgentIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AgentRouteRoute, +} as any) const HomeIndexRoute = HomeIndexRouteImport.update({ id: '/', path: '/', @@ -140,6 +154,16 @@ const ApiHealthRoute = ApiHealthRouteImport.update({ path: '/api/health', getParentRoute: () => rootRouteImport, } as any) +const AgentNewRoute = AgentNewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => AgentRouteRoute, +} as any) +const AgentThreadIdRoute = AgentThreadIdRouteImport.update({ + id: '/$threadId', + path: '/$threadId', + getParentRoute: () => AgentRouteRoute, +} as any) const DotwellKnownOpenidConfigurationRoute = DotwellKnownOpenidConfigurationRouteImport.update({ id: '/.well-known/openid-configuration', @@ -271,6 +295,7 @@ const ApiUploadsUserIdSplatRoute = ApiUploadsUserIdSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof HomeIndexRoute + '/agent': typeof AgentRouteRouteWithChildren '/auth': typeof AuthRouteRouteWithChildren '/dashboard': typeof DashboardRouteRouteWithChildren '/schema.json': typeof SchemaDotjsonRoute @@ -280,6 +305,8 @@ export interface FileRoutesByFullPath { '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute + '/agent/$threadId': typeof AgentThreadIdRoute + '/agent/new': typeof AgentNewRoute '/api/health': typeof ApiHealthRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute '/auth/login': typeof AuthLoginRoute @@ -290,6 +317,7 @@ export interface FileRoutesByFullPath { '/auth/verify-2fa': typeof AuthVerify2faRoute '/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute '/templates/$': typeof TemplatesSplatRoute + '/agent/': typeof AgentIndexRoute '/auth/': typeof AuthIndexRoute '/dashboard/': typeof DashboardIndexRoute '/mcp/': typeof McpIndexRoute @@ -318,6 +346,8 @@ export interface FileRoutesByTo { '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute + '/agent/$threadId': typeof AgentThreadIdRoute + '/agent/new': typeof AgentNewRoute '/api/health': typeof ApiHealthRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute '/auth/login': typeof AuthLoginRoute @@ -329,6 +359,7 @@ export interface FileRoutesByTo { '/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute '/templates/$': typeof TemplatesSplatRoute '/': typeof HomeIndexRoute + '/agent': typeof AgentIndexRoute '/auth': typeof AuthIndexRoute '/dashboard': typeof DashboardIndexRoute '/mcp': typeof McpIndexRoute @@ -353,6 +384,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_home': typeof HomeRouteRouteWithChildren + '/agent': typeof AgentRouteRouteWithChildren '/auth': typeof AuthRouteRouteWithChildren '/dashboard': typeof DashboardRouteRouteWithChildren '/schema.json': typeof SchemaDotjsonRoute @@ -362,6 +394,8 @@ export interface FileRoutesById { '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute + '/agent/$threadId': typeof AgentThreadIdRoute + '/agent/new': typeof AgentNewRoute '/api/health': typeof ApiHealthRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute '/auth/login': typeof AuthLoginRoute @@ -373,6 +407,7 @@ export interface FileRoutesById { '/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute '/templates/$': typeof TemplatesSplatRoute '/_home/': typeof HomeIndexRoute + '/agent/': typeof AgentIndexRoute '/auth/': typeof AuthIndexRoute '/dashboard/': typeof DashboardIndexRoute '/mcp/': typeof McpIndexRoute @@ -398,6 +433,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/agent' | '/auth' | '/dashboard' | '/schema.json' @@ -407,6 +443,8 @@ export interface FileRouteTypes { | '/.well-known/oauth-authorization-server' | '/.well-known/oauth-protected-resource' | '/.well-known/openid-configuration' + | '/agent/$threadId' + | '/agent/new' | '/api/health' | '/auth/forgot-password' | '/auth/login' @@ -417,6 +455,7 @@ export interface FileRouteTypes { | '/auth/verify-2fa' | '/auth/verify-2fa-backup' | '/templates/$' + | '/agent/' | '/auth/' | '/dashboard/' | '/mcp/' @@ -445,6 +484,8 @@ export interface FileRouteTypes { | '/.well-known/oauth-authorization-server' | '/.well-known/oauth-protected-resource' | '/.well-known/openid-configuration' + | '/agent/$threadId' + | '/agent/new' | '/api/health' | '/auth/forgot-password' | '/auth/login' @@ -456,6 +497,7 @@ export interface FileRouteTypes { | '/auth/verify-2fa-backup' | '/templates/$' | '/' + | '/agent' | '/auth' | '/dashboard' | '/mcp' @@ -479,6 +521,7 @@ export interface FileRouteTypes { id: | '__root__' | '/_home' + | '/agent' | '/auth' | '/dashboard' | '/schema.json' @@ -488,6 +531,8 @@ export interface FileRouteTypes { | '/.well-known/oauth-authorization-server' | '/.well-known/oauth-protected-resource' | '/.well-known/openid-configuration' + | '/agent/$threadId' + | '/agent/new' | '/api/health' | '/auth/forgot-password' | '/auth/login' @@ -499,6 +544,7 @@ export interface FileRouteTypes { | '/auth/verify-2fa-backup' | '/templates/$' | '/_home/' + | '/agent/' | '/auth/' | '/dashboard/' | '/mcp/' @@ -523,6 +569,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { HomeRouteRoute: typeof HomeRouteRouteWithChildren + AgentRouteRoute: typeof AgentRouteRouteWithChildren AuthRouteRoute: typeof AuthRouteRouteWithChildren DashboardRouteRoute: typeof DashboardRouteRouteWithChildren SchemaDotjsonRoute: typeof SchemaDotjsonRoute @@ -566,6 +613,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthRouteRouteImport parentRoute: typeof rootRouteImport } + '/agent': { + id: '/agent' + path: '/agent' + fullPath: '/agent' + preLoaderRoute: typeof AgentRouteRouteImport + parentRoute: typeof rootRouteImport + } '/_home': { id: '/_home' path: '' @@ -594,6 +648,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthIndexRouteImport parentRoute: typeof AuthRouteRoute } + '/agent/': { + id: '/agent/' + path: '/' + fullPath: '/agent/' + preLoaderRoute: typeof AgentIndexRouteImport + parentRoute: typeof AgentRouteRoute + } '/_home/': { id: '/_home/' path: '/' @@ -671,6 +732,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHealthRouteImport parentRoute: typeof rootRouteImport } + '/agent/new': { + id: '/agent/new' + path: '/new' + fullPath: '/agent/new' + preLoaderRoute: typeof AgentNewRouteImport + parentRoute: typeof AgentRouteRoute + } + '/agent/$threadId': { + id: '/agent/$threadId' + path: '/$threadId' + fullPath: '/agent/$threadId' + preLoaderRoute: typeof AgentThreadIdRouteImport + parentRoute: typeof AgentRouteRoute + } '/.well-known/openid-configuration': { id: '/.well-known/openid-configuration' path: '/.well-known/openid-configuration' @@ -847,6 +922,22 @@ const HomeRouteRouteWithChildren = HomeRouteRoute._addFileChildren( HomeRouteRouteChildren, ) +interface AgentRouteRouteChildren { + AgentThreadIdRoute: typeof AgentThreadIdRoute + AgentNewRoute: typeof AgentNewRoute + AgentIndexRoute: typeof AgentIndexRoute +} + +const AgentRouteRouteChildren: AgentRouteRouteChildren = { + AgentThreadIdRoute: AgentThreadIdRoute, + AgentNewRoute: AgentNewRoute, + AgentIndexRoute: AgentIndexRoute, +} + +const AgentRouteRouteWithChildren = AgentRouteRoute._addFileChildren( + AgentRouteRouteChildren, +) + interface AuthRouteRouteChildren { AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute AuthLoginRoute: typeof AuthLoginRoute @@ -948,6 +1039,7 @@ const DotwellKnownOauthProtectedResourceRouteWithChildren = const rootRouteChildren: RootRouteChildren = { HomeRouteRoute: HomeRouteRouteWithChildren, + AgentRouteRoute: AgentRouteRouteWithChildren, AuthRouteRoute: AuthRouteRouteWithChildren, DashboardRouteRoute: DashboardRouteRouteWithChildren, SchemaDotjsonRoute: SchemaDotjsonRoute, diff --git a/apps/web/src/routes/agent/$threadId.tsx b/apps/web/src/routes/agent/$threadId.tsx new file mode 100644 index 000000000..e25612829 --- /dev/null +++ b/apps/web/src/routes/agent/$threadId.tsx @@ -0,0 +1,1243 @@ +import type { UIMessage, UIMessageChunk } from "ai"; +import type * as React from "react"; +import type { PanelImperativeHandle } from "react-resizable-panels"; +import type { RouterOutput } from "@/libs/orpc/client"; +import { useChat } from "@ai-sdk/react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { eventIteratorToUnproxiedDataStream } from "@orpc/client"; +import { + ArchiveIcon, + ArrowClockwiseIcon, + ArrowSquareOutIcon, + ChatCircleDotsIcon, + CircleNotchIcon, + ClockCounterClockwiseIcon, + CopyIcon, + DotsThreeVerticalIcon, + FileIcon, + FilePdfIcon, + MinusIcon, + PaperclipIcon, + PaperPlaneRightIcon, + PlusIcon, + SidebarSimpleIcon, + SparkleIcon, + SquaresFourIcon, + StopIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; +import { motion } from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { toast } from "sonner"; +import { Badge } from "@reactive-resume/ui/components/badge"; +import { Button } from "@reactive-resume/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@reactive-resume/ui/components/dropdown-menu"; +import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable"; +import { ScrollArea } from "@reactive-resume/ui/components/scroll-area"; +import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs"; +import { Textarea } from "@reactive-resume/ui/components/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip"; +import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file"; +import { cn } from "@reactive-resume/utils/style"; +import { ResumePreview } from "@/components/resume/preview"; +import { useConfirm } from "@/hooks/use-confirm"; +import { getOrpcErrorMessage } from "@/libs/error-message"; +import { client, orpc, streamClient } from "@/libs/orpc/client"; +import { createResumePdfBlob } from "@/libs/resume/pdf-document"; +import { AgentThreadSidebar } from "./-components/thread-sidebar"; +import { attachmentIdsFromTransportBody, buildAgentChatSubmission } from "./-helpers/chat-attachments"; + +type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"]; +type AgentAction = AgentThreadDetail["actions"][number]; +type AgentAttachment = AgentThreadDetail["attachments"][number]; +type PatchOperation = AgentAction["operations"][number]; + +function toRecord(value: unknown) { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function PatchToolCard({ + part, + action, + onRevert, + isReverting, +}: { + part: UIMessage["parts"][number]; + action: AgentAction | undefined; + onRevert: (actionId: string) => void; + isReverting: boolean; +}) { + const partRecord = part as Record; + const state = typeof partRecord.state === "string" ? partRecord.state : null; + const input = toRecord(partRecord.input); + const output = toRecord(partRecord.output); + const actionId = + state === "output-available" + ? (action?.id ?? (typeof output?.actionId === "string" ? output.actionId : null)) + : null; + + const title = + action?.title ?? + (typeof output?.title === "string" ? output.title : null) ?? + (typeof input?.title === "string" ? input.title : t`Resume patch`); + const operations: PatchOperation[] = + action?.operations ?? + (Array.isArray(output?.operations) + ? (output.operations as PatchOperation[]) + : Array.isArray(input?.operations) + ? (input.operations as PatchOperation[]) + : []); + const status = action?.status ?? "applied"; + const revertMessage = action?.revertMessage ?? null; + const label = + state === "output-error" + ? t`Patch failed` + : state !== "output-available" + ? t`Patch pending` + : status === "reverted" + ? t`Patch reverted` + : status === "conflicted" + ? t`Patch conflicted` + : t`Patch applied`; + const revertDisabled = isReverting || status === "reverted" || status === "conflicted"; + const errorText = typeof partRecord.errorText === "string" ? partRecord.errorText : null; + const rawPayload = JSON.stringify( + { + state, + input, + ...(partRecord.rawInput !== undefined ? { rawInput: partRecord.rawInput } : {}), + output, + ...(errorText ? { errorText } : {}), + ...(action ? { action } : {}), + operations, + }, + null, + 2, + ); + + return ( +
+ + {label} + {title} + + +
+
+
+

{title}

+ {status === "conflicted" && revertMessage ? ( +

{revertMessage}

+ ) : null} + {errorText ?

{errorText}

: null} +
+ {actionId ? ( + + ) : null} +
+
+					{rawPayload}
+				
+
+
+ ); +} + +export const Route = createFileRoute("/agent/$threadId")({ + component: RouteComponent, + ssr: false, +}); + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(",")[1] ?? ""); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function textFromMessage(message: UIMessage) { + return message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); +} + +function parseAgentSseStream(stream: ReadableStream) { + let buffer = ""; + const eventBoundary = /\r?\n\r?\n/; + + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += chunk; + + let boundary = eventBoundary.exec(buffer); + while (boundary) { + const event = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary[0].length); + + for (const line of event.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + + const data = line.slice("data:".length).trimStart(); + if (!data || data === "[DONE]") continue; + + try { + controller.enqueue(JSON.parse(data) as UIMessageChunk); + } catch (error) { + console.warn("[agent] dropping malformed SSE frame", error); + } + } + + boundary = eventBoundary.exec(buffer); + } + }, + }), + ); +} + +function promptPreview(prompt: string) { + const words = prompt.split(/\s+/).filter(Boolean); + return `${words.slice(0, 7).join(" ")}${words.length > 7 ? "..." : ""}`; +} + +function chunkPrompts(prompts: string[], columns: number) { + return prompts.reduce( + (rows, prompt, index) => { + rows[index % columns]?.push(prompt); + return rows; + }, + Array.from({ length: columns }, () => []), + ); +} + +function StarterPromptMarquee({ onSelect }: { onSelect: (prompt: string) => void }) { + const prompts = [ + t`Tailor this resume to a product manager job description and emphasize roadmap ownership, stakeholder communication, and measurable launch outcomes.`, + t`Compare this resume against this role URL and update keywords while keeping the voice concise and credible.`, + t`Find weak bullets and rewrite them with stronger outcomes, numbers, scope, and sharper verbs.`, + t`Rework the summary so it targets a senior engineering manager role without sounding generic.`, + t`Identify gaps for an applicant tracking system and apply only high-confidence keyword improvements.`, + t`Rewrite this resume for a startup founder-to-product-lead transition with clear business impact.`, + t`Make the experience section more results-oriented and remove vague responsibilities.`, + t`Adjust the resume for a remote-first role that values async communication and ownership.`, + t`Review the resume against a job description and ask me questions before changing uncertain sections.`, + t`Tighten the skills section so it supports the target role instead of reading like a keyword dump.`, + t`Update project bullets to show leadership, constraints, tradeoffs, and measurable outcomes.`, + t`Prepare a conservative patch that improves clarity without changing my career narrative.`, + ]; + + const promptRows = chunkPrompts(prompts, 3); + + return ( +
+ {promptRows.map((row, rowIndex) => { + const marqueePrompts = [...row, ...row, ...row]; + const duration = 135 + rowIndex * 22; + const animate = rowIndex % 2 === 0 ? { x: ["0%", "-33.333%"] } : { x: ["-33.333%", "0%"] }; + + return ( + + {marqueePrompts.map((prompt, index) => ( + + ))} + + ); + })} +
+ ); +} + +function AssistantMarkdown({ text }: { text: string }) { + return ( +

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + a: ({ children, href }) => ( + + {children} + + ), + code: ({ children, className }) => ( + + {children} + + ), + pre: ({ children }) => ( +
    +						{children}
    +					
    + ), + blockquote: ({ children }) => ( +
    {children}
    + ), + }} + > + {text} +
    + ); +} + +function MessagePart({ + part, + isUser, + onAnswer, + onRevert, + isReverting, + actionsById, +}: { + part: UIMessage["parts"][number]; + isUser: boolean; + onAnswer: (toolCallId: string, answer: string) => void; + onRevert: (actionId: string) => void; + isReverting: boolean; + actionsById: Map; +}) { + if (part.type === "text") { + return isUser ? ( +
    {part.text}
    + ) : ( + + ); + } + + if (part.type === "reasoning") { + return ( +
    + + Thinking + +
    {part.text}
    +
    + ); + } + + if (part.type === "tool-ask_user_question") { + const input = + "input" in part && typeof part.input === "object" && part.input ? (part.input as Record) : {}; + const choices = Array.isArray(input.choices) + ? input.choices.filter((choice): choice is string => typeof choice === "string") + : []; + const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`; + + return ( +
    +
    {question}
    +
    + {choices.map((choice) => ( + + ))} +
    +
    + ); + } + + if (part.type === "tool-fetch_url") { + const output = + "output" in part && typeof part.output === "object" && part.output + ? (part.output as Record) + : null; + return ( +
    + + Fetched URL + +
    +

    {typeof output?.url === "string" ? output.url : t`Waiting for fetch result...`}

    + {typeof output?.title === "string" ?

    {output.title}

    : null} +
    +
    + ); + } + + if (part.type === "tool-apply_resume_patch") { + const output = + "output" in part && typeof part.output === "object" && part.output + ? (part.output as Record) + : null; + const actionId = typeof output?.actionId === "string" ? output.actionId : null; + const action = actionId ? actionsById.get(actionId) : undefined; + + return ; + } + + if (part.type === "source-url") { + const title = part.title?.trim() || null; + + return ( + + {title ? ( + <> + {title} + {part.url} + + ) : ( + {part.url} + )} + + ); + } + + if (part.type === "file") { + return ( +
    + + {part.filename ?? part.url} +
    + ); + } + + return null; +} + +function ChatMessage({ + message, + onAnswer, + onRevert, + isReverting, + actionsById, +}: { + message: UIMessage; + onAnswer: (toolCallId: string, answer: string) => void; + onRevert: (actionId: string) => void; + isReverting: boolean; + actionsById: Map; +}) { + const isUser = message.role === "user"; + + return ( +
    +
    + {message.parts.map((part, index) => ( + + ))} +
    +
    + ); +} + +function AgentChat({ + threadId, + initialMessages, + isReadOnly, + readOnlyReason, + threadStatus, + activeRunId, + actions, + onToggleThreads, + onToggleResume, +}: { + threadId: string; + initialMessages: UIMessage[]; + isReadOnly: boolean; + readOnlyReason: "archived" | "missing" | null; + threadStatus: string; + activeRunId: string | null; + actions: AgentAction[]; + onToggleThreads?: () => void; + onToggleResume?: () => void; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const confirm = useConfirm(); + const fileInputRef = useRef(null); + const refreshedPatchOutputsRef = useRef(new Set()); + const lastSyncedThreadIdRef = useRef(null); + const [input, setInput] = useState(""); + const [pendingAttachments, setPendingAttachments] = useState< + Array> + >([]); + const [isUploading, setIsUploading] = useState(false); + const revertMutation = useMutation(orpc.agent.actions.revert.mutationOptions()); + const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions()); + const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions()); + const isArchived = threadStatus === "archived"; + + const refreshThread = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }) }), + ]); + }, [queryClient, threadId]); + + const actionsById = useMemo(() => { + const map = new Map(); + for (const action of actions) map.set(action.id, action); + return map; + }, [actions]); + + const handleArchive = () => { + archiveMutation.mutate( + { id: threadId }, + { + onSuccess: async () => { + toast.success(t`Thread archived.`); + await refreshThread(); + }, + onError: (error) => { + toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` })); + }, + }, + ); + }; + + const handleDelete = async () => { + const confirmation = await confirm(t`Delete this agent thread?`, { + description: t`This action cannot be undone. Conversation messages and uploaded attachments will be removed. The working resume draft remains in your dashboard and can be deleted separately.`, + }); + + if (!confirmation) return; + + deleteMutation.mutate( + { id: threadId }, + { + onSuccess: async () => { + toast.success(t`Thread deleted.`); + await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }); + void navigate({ to: "/agent" }); + }, + onError: (error) => { + toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` })); + }, + }, + ); + }; + + const transport = useMemo( + () => ({ + async sendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal; body?: object }) { + const message = options.messages.at(-1); + if (!message) throw new Error("No message to send."); + const attachmentIds = attachmentIdsFromTransportBody(options.body); + + return parseAgentSseStream( + eventIteratorToUnproxiedDataStream( + await streamClient.agent.messages.send( + { threadId, message, attachmentIds }, + { signal: options.abortSignal }, + ), + ), + ); + }, + async reconnectToStream() { + return parseAgentSseStream( + eventIteratorToUnproxiedDataStream(await streamClient.agent.messages.resume({ threadId })), + ); + }, + }), + [threadId], + ); + + const { messages, sendMessage, regenerate, setMessages, status, error, clearError, addToolOutput } = useChat({ + id: threadId, + messages: initialMessages, + resume: !!activeRunId, + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onFinish: () => { + void refreshThread(); + }, + }); + + useEffect(() => { + let shouldRefresh = false; + + for (const message of messages) { + for (const part of message.parts) { + if (part.type !== "tool-apply_resume_patch" || !("output" in part) || !part.output) continue; + + const output = typeof part.output === "object" ? (part.output as Record) : null; + const actionId = typeof output?.actionId === "string" ? output.actionId : null; + const toolCallId = "toolCallId" in part && typeof part.toolCallId === "string" ? part.toolCallId : null; + const patchOutputKey = actionId ?? toolCallId; + + if (!patchOutputKey || refreshedPatchOutputsRef.current.has(patchOutputKey)) continue; + + refreshedPatchOutputsRef.current.add(patchOutputKey); + shouldRefresh = true; + } + } + + if (shouldRefresh) void refreshThread(); + }, [messages, refreshThread]); + + useEffect(() => { + if (lastSyncedThreadIdRef.current === threadId) return; + lastSyncedThreadIdRef.current = threadId; + setMessages(initialMessages); + }, [threadId, initialMessages, setMessages]); + + const isStreaming = status === "submitted" || status === "streaming"; + + const send = () => { + const text = input.trim(); + if ((!text && pendingAttachments.length === 0) || isReadOnly || isStreaming || isUploading) return; + + clearError(); + const submission = buildAgentChatSubmission(text, pendingAttachments); + sendMessage(submission.message, submission.options); + setInput(""); + setPendingAttachments([]); + }; + + const uploadFiles = async (files: FileList | null) => { + if (!files?.length) return; + + setIsUploading(true); + try { + for (const file of Array.from(files)) { + const attachment = await client.agent.attachments.create({ + threadId, + filename: file.name, + mediaType: file.type || "application/octet-stream", + data: await fileToBase64(file), + }); + setPendingAttachments((current) => [ + ...current, + { id: attachment.id, filename: attachment.filename, mediaType: attachment.mediaType }, + ]); + } + toast.success(t`Attachment uploaded.`); + } catch (error) { + toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` })); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const stopRun = async () => { + const last = messages.at(-1); + await client.agent.messages.stop({ + threadId, + ...(last?.role === "assistant" ? { partialMessage: last } : {}), + }); + }; + + const copyConversationJson = () => { + void navigator.clipboard.writeText( + JSON.stringify( + { + threadId, + threadStatus, + chatStatus: status, + isReadOnly, + readOnlyReason, + messages, + actions, + }, + null, + 2, + ), + ); + toast.success(t`Conversation JSON copied.`); + }; + + return ( +
    +
    +
    + {onToggleThreads ? ( + + ) : null} + +
    + Chat +
    +
    +
    + {onToggleResume ? ( + + ) : null} + + + + + Thread actions + + + } + /> + + + { + void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n")); + toast.success(t`Conversation copied.`); + }} + > + + Copy + + + + Copy JSON + + + + + {!isArchived ? ( + + + Archive + + ) : null} + + void handleDelete()} + > + + Delete + + + +
    +
    + + {isReadOnly ? ( +
    + {readOnlyReason === "archived" ? ( + This thread is archived. New messages cannot be sent. + ) : ( + This thread is read-only because the working resume or AI provider is unavailable. + )} +
    + ) : null} + + +
    + {messages.length === 0 ? ( +
    + +

    + What do you want to do? +

    + +
    + ) : null} + + {messages.map((message) => ( + { + addToolOutput({ tool: "ask_user_question", toolCallId, output: answer }); + }} + onRevert={(actionId) => + revertMutation.mutate( + { id: actionId }, + { + onSuccess: (action) => { + if (action.status === "conflicted") { + toast.error( + action.revertMessage ?? t`Cannot revert; the resume has changed since this edit was applied.`, + ); + } else if (action.status === "reverted") { + toast.success(t`Patch reverted.`); + } + void refreshThread(); + }, + onError: (error) => + toast.error(getOrpcErrorMessage(error, { fallback: t`Could not revert this patch.` })), + }, + ) + } + /> + ))} + + {isStreaming ? ( +
    +
    + Working... +
    +
    + ) : null} + + {error ? ( +
    + {error.message} + {!isReadOnly ? ( + + ) : null} +
    + ) : null} +
    +
    + +
    { + event.preventDefault(); + send(); + }} + > +
    + {pendingAttachments.length > 0 ? ( +
    + {pendingAttachments.map((attachment) => ( + + + {attachment.filename} + + ))} +
    + ) : null} + +
    + void uploadFiles(event.target.files)} + /> + +