diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d608c829 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# LF in the repository and on disk, on every platform. eol=lf rather than plain text=auto because +# this working tree is shared byte-for-byte through Dropbox: if one machine checked out CRLF the +# other would see the whole tree as modified after every sync. +* text=auto eol=lf + +*.png binary +*.ico binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72be07f9..5395d094 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ Before we can merge your pull request, you must sign our Contributor License Agr ## ๐Ÿ“ฆ Repository Layout -Codra is migrating to an npm workspace monorepo. The repository is structured into `apps/` (deployable entrypoints) and `packages/` (reusable modules): +Codra is an npm workspace monorepo. The repository is structured into `apps/` (deployable entrypoints) and `packages/` (reusable modules): ```text packages/ @@ -31,7 +31,7 @@ apps/ โ””โ”€โ”€ dashboard/ # React SPA frontend (depends on ui, schema) ``` -**Note:** We are incrementally migrating code from the legacy `src/` directory into this workspace structure. New logic should be placed in the appropriate `packages/` or `apps/` directory when possible. +**Note:** The `packages/*` modules are published to npm as `@codraoss/*`; the workspace consumes them as TypeScript source and only the published tarballs carry compiled output. Reusable logic belongs in a package, deployment wiring in `apps/worker`, and UI in `apps/dashboard`. --- @@ -95,10 +95,14 @@ npm run typecheck ## ๐Ÿš€ Pull Request Process -1. **Fork & Branch**: Create a feature branch from `main`. +Contributions are merged into `dev` first and reach `main` when a release is cut, so `main` always +reflects what is deployed and published. Pull requests opened against `main` will be asked to +retarget to `dev`. + +1. **Fork & Branch**: Create a feature branch from `dev`. 2. **Atomic Commits**: Keep your commits focused and descriptive. -3. **Sync**: Ensure your branch is up to date with `main`. -4. **Target Branch**: Open pull requests against `main`. +3. **Sync**: Ensure your branch is up to date with `dev`. +4. **Target Branch**: Open pull requests against `dev`. 5. **PR Description**: Use the provided template (if available) or clearly explain the *what* and *why* of your changes. 6. **CLA Check**: Once you open the PR, an automated check will verify your CLA status. If you haven't signed yet, follow the link in the check output. diff --git a/README.md b/README.md index c85fecf6..91130fa7 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The full setup and operations guides live at [codra.run/docs](https://codra.run/ ## Contributing -Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request against `main`. Codra uses a Contributor License Agreement for contributions. +Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request against `dev`. Codra uses a Contributor License Agreement for contributions. ## License diff --git a/index.html b/apps/dashboard/index.html similarity index 89% rename from index.html rename to apps/dashboard/index.html index ab46f36e..ecc9b210 100644 --- a/index.html +++ b/apps/dashboard/index.html @@ -11,6 +11,6 @@
- + diff --git a/src/client/app.css b/apps/dashboard/src/app.css similarity index 56% rename from src/client/app.css rename to apps/dashboard/src/app.css index ef0a2a65..04e24c96 100644 --- a/src/client/app.css +++ b/apps/dashboard/src/app.css @@ -1,232 +1,14 @@ @import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap'); @import "tailwindcss"; +@import "@codraoss/ui/styles"; -/* dark: utilities follow .dark class, not OS prefers-color-scheme. */ -@custom-variant dark (&:where(.dark, .dark *)); +/* Tailwind v4 scans from the build root (apps/dashboard), so packages/ui would otherwise be missed and its class names silently dropped; @source adds to detection rather than replacing it. */ +@source "../../../packages/ui/src"; +@source ".."; -/* --ui-* : local neutral surface scale (no runtime design-system dep). */ :root { - --ui-base: #ffffff; - --ui-canvas: oklch(98.75% 0 0); - --ui-line: oklch(14.5% 0 0 / 0.1); - --ui-fill: oklch(92.2% 0 0); - --ui-subtle: oklch(55.6% 0 0); - --ui-default: oklch(21% 0 0); - --ui-strong: oklch(14.5% 0 0); -} -.dark { - --ui-base: oklch(17% 0 0); - --ui-canvas: oklch(10% 0 0); - --ui-line: oklch(32% 0 0); - --ui-fill: oklch(26.9% 0 0); - --ui-subtle: oklch(70.8% 0 0); - --ui-default: oklch(97% 0 0); - --ui-strong: oklch(98.5% 0 0); -} - -:root { - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); - --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); -} - -/* LIGHT MODE (:root default) */ -:root { - --background: oklch(100% 0 0); - --foreground: oklch(12% 0.02 115); - --card: oklch(100% 0 0); - --card-foreground: oklch(12% 0.02 115); - --popover: oklch(100% 0 0); - --popover-foreground: oklch(12% 0.02 115); - - /* Lime darkened for AA contrast on white; .dark restores full brightness. */ - --primary: oklch(64% 0.24 115); - --primary-foreground: oklch(100% 0 0); - --btn-primary-bg: oklch(64% 0.24 115); - --btn-primary-fg: oklch(20% 0.02 118); - --btn-primary-border: oklch(72% 0.17 118); - --btn-primary-surface: oklch(95% 0.09 118); - --btn-primary-hover: oklch(90% 0.13 118); - - --secondary: oklch(96.3% 0.003 286.3); - --secondary-foreground:oklch(27.4% 0.006 286.3); - --muted: oklch(96.3% 0.003 286.3); - --muted-foreground: oklch(55.1% 0.011 286.3); - - --accent: oklch(90.9% 0.004 286.3); - --accent-foreground: oklch(20.5% 0.005 286.3); - - --destructive: oklch(55% 0.22 25); - --destructive-foreground: oklch(100% 0 0); - - --border: oklch(90.9% 0.004 286.3); - --input: oklch(90.9% 0.004 286.3); - --ring: oklch(72% 0.22 115); - - --radius: 0.75rem; --sidebar-width: 240px; - - --success: oklch(64% 0.24 115); - --success-bg: oklch(98% 0.04 115); - --success-border: oklch(85% 0.15 115); - --warning: oklch(56% 0.18 65); - --warning-bg: oklch(98% 0.04 65); - --warning-border: oklch(90% 0.12 65); - --danger: oklch(62% 0.22 25); - --danger-bg: oklch(98% 0.04 25); - --danger-border: oklch(88% 0.14 25); - --info: oklch(68% 0.18 250); - --info-bg: oklch(98% 0.04 250); - --info-border: oklch(88% 0.12 250); - - --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.02); - --shadow-md: 0 1px 4px oklch(0% 0 0 / 0.03), 0 1px 2px oklch(0% 0 0 / 0.02); - --shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03); - - --code-bg: oklch(96.3% 0.003 286.3); - --code-fg: oklch(27.4% 0.006 286.3); - --code-border: oklch(90.9% 0.004 286.3); - - /* True green/red, not brand lime, so diff rows/counts stay distinguishable. */ - --diff-add-bg: oklch(95% 0.06 150); - --diff-add-fg: oklch(48% 0.13 150); - --diff-del-bg: oklch(95% 0.05 27); - --diff-del-fg: oklch(52% 0.16 27); -} - -/* DARK MODE (.dark class on ) */ -.dark { - --background: #000000; - --foreground: oklch(98% 0.005 115); - --card: #09090b; - --card-foreground: oklch(98% 0.005 115); - --popover: #09090b; - --popover-foreground: oklch(98% 0.005 115); - - --primary: oklch(94% 0.23 115); - --primary-foreground: oklch(12% 0.04 115); - - --btn-primary-bg: #CCE800; - --btn-primary-fg: #CCE800; - --btn-primary-border: color-mix(in oklab, #CCE800 50%, transparent); - --btn-primary-surface: color-mix(in oklab, #CCE800 8%, transparent); - --btn-primary-hover: color-mix(in oklab, #CCE800 16%, transparent); - - --secondary: oklch(18% 0.018 115); - --secondary-foreground:oklch(82% 0.012 115); - --muted: oklch(18% 0.018 115); - --muted-foreground: oklch(55% 0.015 115); - - --accent: oklch(18% 0.018 115); - --accent-foreground: oklch(91% 0.010 115); - - --destructive: oklch(60% 0.220 25); - --destructive-foreground: oklch(10% 0.015 115); - - --border: oklch(22% 0.02 115); - --input: oklch(22% 0.02 115); - --ring: oklch(94% 0.23 115); - - --success: oklch(94% 0.23 115); - --success-bg: oklch(18% 0.06 115); - --success-border: oklch(28% 0.10 115); - --warning: oklch(78% 0.165 65); - --warning-bg: oklch(18% 0.080 65); - --warning-border: oklch(35% 0.14 65); - --danger: oklch(70% 0.200 25); - --danger-bg: oklch(18% 0.080 25); - --danger-border: oklch(35% 0.14 25); - --info: oklch(72% 0.160 250); - --info-bg: oklch(18% 0.075 250); - --info-border: oklch(35% 0.12 250); - - --shadow-sm: 0 1px 2px oklch(100% 0 0 / 0.05), 0 1px 2px oklch(0% 0 0 / 0.3); - --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.45), 0 1px 4px oklch(0% 0 0 / 0.25); - --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.5), 0 4px 12px -2px oklch(0% 0 0 / 0.3); - - --code-bg: oklch(20.5% 0.005 286.3); - --code-fg: oklch(86.5% 0.005 286.3); - --code-border: oklch(27.4% 0.006 286.3); - - --diff-add-bg: oklch(30% 0.06 150); - --diff-add-fg: oklch(82% 0.15 150); - --diff-del-bg: oklch(31% 0.08 27); - --diff-del-fg: oklch(80% 0.16 27); -} - -/* Tailwind v4 theme tokens (@theme inline = dynamic) */ -@theme inline { - --font-sans: 'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - - /* References raw --ui-* vars so utilities flip with .dark. */ - --color-ui-base: var(--ui-base); - --color-ui-canvas: var(--ui-canvas); - --color-ui-line: var(--ui-line); - --color-ui-fill: var(--ui-fill); - --color-ui-subtle: var(--ui-subtle); - --color-ui-default: var(--ui-default); - --color-ui-strong: var(--ui-strong); - --color-ui-brand: var(--primary); - - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground:var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - - --color-success: var(--success); - --color-success-bg: var(--success-bg); - --color-success-border: var(--success-border); - - --color-warning: var(--warning); - --color-warning-bg: var(--warning-bg); - --color-warning-border: var(--warning-border); - - --color-danger: var(--danger); - --color-danger-bg: var(--danger-bg); - --color-danger-border: var(--danger-border); - - --color-info: var(--info); - --color-info-bg: var(--info-bg); - --color-info-border: var(--info-border); - - /* radius-lg == radius-xl intentionally: cards and .surface share one size. */ - --radius-sm: 0.3125rem; - --radius-md: 0.4375rem; - --radius-lg: 0.6875rem; - --radius-xl: 0.6875rem; - --radius-2xl: 0.875rem; - - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: clamp(1.125rem, 2vw, 1.25rem); - --text-xl: clamp(1.25rem, 3vw, 1.5rem); - --text-2xl: clamp(1.5rem, 4vw, 2.25rem); - --text-3xl: clamp(2rem, 6vw, 3.5rem); - --text-4xl: clamp(2.5rem, 10vw, 6rem); - --text-display: clamp(3rem, 12vw, 9rem); - - --space-xs: clamp(0.5rem, 1vw, 0.75rem); - --space-sm: clamp(1rem, 2vw, 1.5rem); - --space-md: clamp(1.5rem, 4vw, 3rem); - --space-lg: clamp(3rem, 8vw, 6rem); - --space-xl: clamp(6rem, 12vw, 10rem); } @layer base { @@ -278,10 +60,6 @@ pre, code { font-family: var(--font-mono); } } -@keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} @keyframes pulse-ring { 0%, 100% { opacity: 1; transform: scale(1); } @@ -431,7 +209,6 @@ } } -/* Toggled by JS IntersectionObserver. */ @utility reveal-on-scroll { opacity: 0; transform: translateY(24px); @@ -456,7 +233,6 @@ @utility reveal-delay-3 { transition-delay: 240ms !important; } @utility reveal-delay-4 { transition-delay: 320ms !important; } -/* Scrollbars: neutral grey, auto-hidden via app-shell.tsx toggling data-scrolling. */ * { scrollbar-width: thin; scrollbar-color: transparent transparent; @@ -469,7 +245,6 @@ scrollbar-color: oklch(100% 0 0 / 0.3) transparent; } -/* Transparent border + padding-box clip insets the thumb into a slim bar. */ ::-webkit-scrollbar { width: 10px; height: 10px; @@ -508,67 +283,7 @@ } } -@utility surface { - @apply bg-card border border-border rounded-xl; - box-shadow: var(--shadow-md); -} - -.surface-static { - transition: none !important; -} - -.surface-static:hover { - box-shadow: var(--shadow-md) !important; - transform: none !important; -} - -.surface-static-shadow { - box-shadow: var(--shadow-md) !important; - transition: none !important; -} - -.surface-static-shadow:hover { - box-shadow: var(--shadow-md) !important; - transform: none !important; -} -@utility glass { - @apply backdrop-blur-md bg-card/75 border border-border; - background-image: linear-gradient(to bottom right, oklch(100% 0 0 / 0.05), transparent); -} - -@utility surface-hover { - @apply transition-all duration-300; - &:hover { - @apply border-primary/30 shadow-lg shadow-primary/5 -translate-y-[1px]; - } -} - -@utility skeleton { - background: linear-gradient( - 90deg, - var(--muted) 25%, - color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, - var(--muted) 75% - ); - background-size: 200% 100%; - @apply animate-[shimmer_1.8s_linear_infinite] rounded-sm; -} - -/* Unlayered, kept at this specificity so no later utility can beat it. */ -.skeleton { - background: - linear-gradient( - 90deg, - var(--muted) 25%, - color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, - var(--muted) 75% - ) !important; - background-size: 200% 100% !important; - animation: shimmer 1.8s linear infinite !important; -} - -/* Model-call bars: override default lime fill with info/blue. */ .meter-indicator-info { background-image: none !important; background-color: var(--info) !important; @@ -651,7 +366,6 @@ } .dark .app-shell-content { - /* Cool neutral hue 286.3, not hue 115 which gave the card a warm olive cast. */ --background: oklch(18% 0.006 286.3); --card: oklch(18% 0.006 286.3); --muted: oklch(22% 0.006 286.3); @@ -661,14 +375,12 @@ --input: oklch(22% 0.006 286.3); } -/* SharedLayoutBg pill is the sole hover affordance; row itself never transforms. */ .dashboard-sidebar-action:hover, .dashboard-sidebar-action:focus-visible, .dashboard-sidebar-action:active { transform: none !important; } -/* Light beam parked off-screen, sweeps across once on hover/focus. */ .dashboard-sidebar-shine { transform: skew(-13deg) translateX(-130%); transition: transform 0ms linear; @@ -702,56 +414,7 @@ @apply text-2xl md:text-3xl lg:text-[2.25rem] font-bold tracking-[-0.04em] leading-none text-foreground tabular-nums; } -/* Geist, scoped locally since global @theme sets --font-sans/mono to app defaults. */ -.ui-font-sans { - font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; -} -.ui-font-mono { - font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-feature-settings: 'tnum' 1; -} -/* Matches dashboard stat-card chrome; .ui-well is its recessed inner panel. */ -.ui-panel { - font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; - border-radius: var(--radius-lg); - border: 1px solid var(--ui-line); - background: #ffffff; -} -.dark .ui-panel { - background: #000000; - border-color: oklch(0.27 0 0); -} -.ui-well { - background: oklch(97.8% 0.002 286.3); -} -.dark .ui-well { - background: oklch(19% 0 0); -} - -/* Syntax tokens for sugar-high (src/client/lib/highlight.tsx); it emits - color: var(--sh-) per token. */ -:root { - --sh-keyword: oklch(48% 0.19 305); - --sh-string: oklch(46% 0.12 150); - --sh-class: oklch(50% 0.13 65); - --sh-comment: oklch(58% 0.01 260); - --sh-entity: oklch(46% 0.14 260); - --sh-property: oklch(45% 0.11 200); - --sh-identifier: inherit; - --sh-sign: oklch(58% 0.01 260); - --sh-jsxliterals: inherit; - --sh-break: inherit; - --sh-space: inherit; -} -.dark { - --sh-keyword: oklch(75% 0.14 305); - --sh-string: oklch(76% 0.11 150); - --sh-class: oklch(78% 0.12 65); - --sh-comment: oklch(58% 0.01 260); - --sh-entity: oklch(76% 0.1 260); - --sh-property: oklch(78% 0.1 200); -} .sh__token--comment { font-style: italic; } .diff-add { background-color: var(--diff-add-bg); } @@ -792,7 +455,6 @@ } .diff-tree-children { display: grid; - /* Implicit column would size to content (auto); pin full width so rows stretch edge-to-edge. */ grid-template-columns: minmax(0, 1fr); grid-template-rows: 1fr; transition: grid-template-rows 0.25s ease-in-out; @@ -805,8 +467,7 @@ min-width: 0; } -/* .thin-scroll / .auto-hide-scroll kept as no-op aliases: the treatment is - now global (Scrollbars block above); existing markup referencing them still works. */ +/* .thin-scroll and .auto-hide-scroll are kept as no-op aliases: the treatment is global now, and existing markup referencing them still works. */ .diff-tree-scroll { overscroll-behavior: contain; @@ -851,7 +512,6 @@ & hr { @apply border-border my-[1.5em]; } } -/* Sonner toast overrides */ [data-sonner-toaster] { --offset: 1.25rem !important; @@ -936,7 +596,6 @@ background: oklch(28% 0.022 115) !important; } -/* Status color comes from the icon; text stays the default toast color. */ .codra-toast-loader svg { color: var(--primary) !important; } diff --git a/src/client/assets/codra-fullicon-dark.svg b/apps/dashboard/src/assets/codra-fullicon-dark.svg similarity index 100% rename from src/client/assets/codra-fullicon-dark.svg rename to apps/dashboard/src/assets/codra-fullicon-dark.svg diff --git a/src/client/assets/codra-fullicon-light.svg b/apps/dashboard/src/assets/codra-fullicon-light.svg similarity index 100% rename from src/client/assets/codra-fullicon-light.svg rename to apps/dashboard/src/assets/codra-fullicon-light.svg diff --git a/src/client/assets/icons/nit-icon.svg b/apps/dashboard/src/assets/icons/nit-icon.svg similarity index 100% rename from src/client/assets/icons/nit-icon.svg rename to apps/dashboard/src/assets/icons/nit-icon.svg diff --git a/src/client/assets/icons/p0-icon.svg b/apps/dashboard/src/assets/icons/p0-icon.svg similarity index 100% rename from src/client/assets/icons/p0-icon.svg rename to apps/dashboard/src/assets/icons/p0-icon.svg diff --git a/src/client/assets/icons/p1-icon.svg b/apps/dashboard/src/assets/icons/p1-icon.svg similarity index 100% rename from src/client/assets/icons/p1-icon.svg rename to apps/dashboard/src/assets/icons/p1-icon.svg diff --git a/src/client/assets/icons/p2-icon.svg b/apps/dashboard/src/assets/icons/p2-icon.svg similarity index 100% rename from src/client/assets/icons/p2-icon.svg rename to apps/dashboard/src/assets/icons/p2-icon.svg diff --git a/src/client/assets/icons/p3-icon.svg b/apps/dashboard/src/assets/icons/p3-icon.svg similarity index 100% rename from src/client/assets/icons/p3-icon.svg rename to apps/dashboard/src/assets/icons/p3-icon.svg diff --git a/src/client/components/features/account/detail-rows.tsx b/apps/dashboard/src/components/features/account/detail-rows.tsx similarity index 100% rename from src/client/components/features/account/detail-rows.tsx rename to apps/dashboard/src/components/features/account/detail-rows.tsx diff --git a/src/client/components/features/account/details-section.tsx b/apps/dashboard/src/components/features/account/details-section.tsx similarity index 100% rename from src/client/components/features/account/details-section.tsx rename to apps/dashboard/src/components/features/account/details-section.tsx diff --git a/src/client/components/features/account/profile-card.tsx b/apps/dashboard/src/components/features/account/profile-card.tsx similarity index 100% rename from src/client/components/features/account/profile-card.tsx rename to apps/dashboard/src/components/features/account/profile-card.tsx diff --git a/src/client/components/features/dashboard/updates-email-prompt.tsx b/apps/dashboard/src/components/features/dashboard/updates-email-prompt.tsx similarity index 100% rename from src/client/components/features/dashboard/updates-email-prompt.tsx rename to apps/dashboard/src/components/features/dashboard/updates-email-prompt.tsx diff --git a/src/client/components/features/job-detail/comment-card.tsx b/apps/dashboard/src/components/features/job-detail/comment-card.tsx similarity index 100% rename from src/client/components/features/job-detail/comment-card.tsx rename to apps/dashboard/src/components/features/job-detail/comment-card.tsx diff --git a/src/client/components/features/job-detail/constants.ts b/apps/dashboard/src/components/features/job-detail/constants.ts similarity index 100% rename from src/client/components/features/job-detail/constants.ts rename to apps/dashboard/src/components/features/job-detail/constants.ts diff --git a/src/client/components/features/job-detail/context-snippet.tsx b/apps/dashboard/src/components/features/job-detail/context-snippet.tsx similarity index 100% rename from src/client/components/features/job-detail/context-snippet.tsx rename to apps/dashboard/src/components/features/job-detail/context-snippet.tsx diff --git a/src/client/components/features/job-detail/diff-file-panel-utils.ts b/apps/dashboard/src/components/features/job-detail/diff-file-panel-utils.ts similarity index 100% rename from src/client/components/features/job-detail/diff-file-panel-utils.ts rename to apps/dashboard/src/components/features/job-detail/diff-file-panel-utils.ts diff --git a/src/client/components/features/job-detail/diff-file-panel.tsx b/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx similarity index 100% rename from src/client/components/features/job-detail/diff-file-panel.tsx rename to apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx diff --git a/src/client/components/features/job-detail/diff-file-tree.tsx b/apps/dashboard/src/components/features/job-detail/diff-file-tree.tsx similarity index 100% rename from src/client/components/features/job-detail/diff-file-tree.tsx rename to apps/dashboard/src/components/features/job-detail/diff-file-tree.tsx diff --git a/src/client/components/features/job-detail/file-finding.tsx b/apps/dashboard/src/components/features/job-detail/file-finding.tsx similarity index 100% rename from src/client/components/features/job-detail/file-finding.tsx rename to apps/dashboard/src/components/features/job-detail/file-finding.tsx diff --git a/src/client/components/features/job-detail/job-chip-utils.ts b/apps/dashboard/src/components/features/job-detail/job-chip-utils.ts similarity index 100% rename from src/client/components/features/job-detail/job-chip-utils.ts rename to apps/dashboard/src/components/features/job-detail/job-chip-utils.ts diff --git a/src/client/components/features/job-detail/job-chips.tsx b/apps/dashboard/src/components/features/job-detail/job-chips.tsx similarity index 96% rename from src/client/components/features/job-detail/job-chips.tsx rename to apps/dashboard/src/components/features/job-detail/job-chips.tsx index 6dcf87ba..cfa273b9 100644 --- a/src/client/components/features/job-detail/job-chips.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-chips.tsx @@ -1,171 +1,171 @@ -// Row vocabulary shared with the job detail page, mirroring the jobs table. -import { useState, type ReactNode } from 'react'; -import { CheckCircle2, MessageSquare, type LucideIcon } from 'lucide-react'; -import { cn } from '@codraoss/ui/utils'; -import { STATUS_DOT, jobDuration, statusLabel } from '@client/lib/job-format'; - -import type { JobDetail, JobSummary } from '@codraoss/schema'; - - -export function StatusDot({ status, className }: { status: string; className?: string }) { - return ( - - ); -} - -// Mirrors the jobs table's status cell. -export function StatusLine({ - status, - duration, - className, -}: { - status: string; - duration?: string | null; - className?: string; -}) { - return ( - - - {statusLabel(status)} - {duration && ( - - {duration} - - )} - - ); -} - -export function JobStatusLine({ job, className }: { job: JobDetail; className?: string }) { - return ; -} - -// Border stays neutral; only the icon carries colour. -export function VerdictPill({ verdict }: { verdict: NonNullable }) { - const approved = verdict === 'approve'; - const Icon = approved ? CheckCircle2 : MessageSquare; - - return ( - - - {verdict} - - ); -} - -export function OutlinePill({ - icon: Icon, - tone, - children, -}: { - icon?: LucideIcon; - tone?: string; - children: ReactNode; -}) { - return ( - - {Icon && } - {children} - - ); -} - -// Mirrors the table's MetaCell. -export function MetaChip({ - icon: Icon, - children, - mono = false, - title, - className, -}: { - icon: LucideIcon; - children: ReactNode; - mono?: boolean; - title?: string; - className?: string; -}) { - return ( - - - - {children} - - - ); -} - -// Hits avatars.githubusercontent.com directly: the github.com/.png redirect can fail. -// No loading="lazy": intersection detection is unreliable in this app's scroll containers. -export function AuthorAvatar({ login, size = 20 }: { login: string | null; size?: number }) { - const [failed, setFailed] = useState(false); - const box = { width: size, height: size }; - - if (!login || failed) { - return ( - - {login?.charAt(0) ?? ''} - - ); - } - - return ( - setFailed(true)} - className="shrink-0 rounded-full bg-ui-fill object-cover ring-1 ring-ui-line" - /> - ); -} - -export function AuthorChip({ login }: { login: string | null }) { - if (!login) return null; - return ( - - - - {login} - - - ); -} - -export function EmptyValue() { - return -; -} - -export function MonoPath({ path, className }: { path: string; className?: string }) { - const slash = path.lastIndexOf('/'); - const dir = slash === -1 ? '' : path.slice(0, slash + 1); - const base = slash === -1 ? path : path.slice(slash + 1); - - return ( - - {dir && {dir}} - {base} - - ); -} +// Row vocabulary shared with the job detail page, mirroring the jobs table. +import { useState, type ReactNode } from 'react'; +import { CheckCircle2, MessageSquare, type LucideIcon } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import { STATUS_DOT, jobDuration, statusLabel } from '@client/lib/job-format'; + +import type { JobDetail, JobSummary } from '@codraoss/schema'; + + +export function StatusDot({ status, className }: { status: string; className?: string }) { + return ( + + ); +} + +// Mirrors the jobs table's status cell. +export function StatusLine({ + status, + duration, + className, +}: { + status: string; + duration?: string | null; + className?: string; +}) { + return ( + + + {statusLabel(status)} + {duration && ( + + {duration} + + )} + + ); +} + +export function JobStatusLine({ job, className }: { job: JobDetail; className?: string }) { + return ; +} + +// Border stays neutral; only the icon carries colour. +export function VerdictPill({ verdict }: { verdict: NonNullable }) { + const approved = verdict === 'approve'; + const Icon = approved ? CheckCircle2 : MessageSquare; + + return ( + + + {verdict} + + ); +} + +export function OutlinePill({ + icon: Icon, + tone, + children, +}: { + icon?: LucideIcon; + tone?: string; + children: ReactNode; +}) { + return ( + + {Icon && } + {children} + + ); +} + +// Mirrors the table's MetaCell. +export function MetaChip({ + icon: Icon, + children, + mono = false, + title, + className, +}: { + icon: LucideIcon; + children: ReactNode; + mono?: boolean; + title?: string; + className?: string; +}) { + return ( + + + + {children} + + + ); +} + +// Hits avatars.githubusercontent.com directly: the github.com/.png redirect can fail. +// No loading="lazy": intersection detection is unreliable in this app's scroll containers. +export function AuthorAvatar({ login, size = 20 }: { login: string | null; size?: number }) { + const [failed, setFailed] = useState(false); + const box = { width: size, height: size }; + + if (!login || failed) { + return ( + + {login?.charAt(0) ?? ''} + + ); + } + + return ( + setFailed(true)} + className="shrink-0 rounded-full bg-ui-fill object-cover ring-1 ring-ui-line" + /> + ); +} + +export function AuthorChip({ login }: { login: string | null }) { + if (!login) return null; + return ( + + + + {login} + + + ); +} + +export function EmptyValue() { + return -; +} + +export function MonoPath({ path, className }: { path: string; className?: string }) { + const slash = path.lastIndexOf('/'); + const dir = slash === -1 ? '' : path.slice(0, slash + 1); + const base = slash === -1 ? path : path.slice(slash + 1); + + return ( + + {dir && {dir}} + {base} + + ); +} diff --git a/src/client/components/features/job-detail/job-diffs.tsx b/apps/dashboard/src/components/features/job-detail/job-diffs.tsx similarity index 100% rename from src/client/components/features/job-detail/job-diffs.tsx rename to apps/dashboard/src/components/features/job-detail/job-diffs.tsx diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/apps/dashboard/src/components/features/job-detail/job-findings-list.tsx similarity index 100% rename from src/client/components/features/job-detail/job-findings-list.tsx rename to apps/dashboard/src/components/features/job-detail/job-findings-list.tsx diff --git a/src/client/components/features/job-detail/job-header.tsx b/apps/dashboard/src/components/features/job-detail/job-header.tsx similarity index 51% rename from src/client/components/features/job-detail/job-header.tsx rename to apps/dashboard/src/components/features/job-detail/job-header.tsx index 081fddf3..8eff1283 100644 --- a/src/client/components/features/job-detail/job-header.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-header.tsx @@ -2,21 +2,10 @@ import { Button, ConfirmDialog } from '@codraoss/ui'; import { useState } from 'react'; import type { ComponentType } from 'react'; import { Link } from 'react-router-dom'; -import { - ChevronRight, - ExternalLink, - FolderGit2, - GitBranch, - GitCommitHorizontal, - GitPullRequest, - Loader2, - RotateCcw, - Terminal, - Trash2, -} from 'lucide-react'; +import { ChevronRight, ExternalLink, Loader2, RotateCcw, Terminal, Trash2 } from 'lucide-react'; import type { ButtonProps } from '@codraoss/ui'; import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; -import { AuthorChip, JobStatusLine, MetaChip, VerdictPill } from './job-chips'; +import { AuthorChip, VerdictPill } from './job-chips'; import { formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; import type { JobDetail } from '@codraoss/schema'; @@ -79,6 +68,15 @@ function JobActionButton({ ); } +/** `ยท` inside a group of related facts, `|` between groups. */ +function Dot() { + return ยท; +} + +function Pipe() { + return |; +} + interface JobHeaderProps { job: JobDetail; isRerunning: boolean; @@ -105,107 +103,112 @@ export function JobHeader({ return ( <> - {/* The header is the detail page's version of a table row: same vocabulary as the jobs table. */} -
-
- {/* Deliberately thin: the repo and PR live in the chip row below, so this only carries the way back and the job id. */} -
- + {/* Full-bleed header: a hairline rule under the breadcrumb bar, then the title and the PR's + coordinates. No card - the panels below are the cards, and framing this too would nest a + surface inside a surface. Status, token counts and the step list live in those panels. */} +
+
+
+ Jobs - - + + {job.id.slice(0, 8)}
-

+
+ + + setStopOpen(true)} + /> + + {/* Always restarts the review from the beginning (every file), regardless of the job's current status. */} + + + setDeleteOpen(true)} + /> +
+

+ +
+

{job.prTitle ?? 'Untitled pull request'} - + -

- -
- - {job.verdict && } + - + {/* Coordinates, in one readable line rather than a row of chips. */} +
+ {job.owner}/{job.repo} - - - - #{job.prNumber} - - + + + #{job.prNumber} {job.commitSha && ( - - {job.commitSha.slice(0, 7)} - + <> + + + {job.commitSha.slice(0, 7)} + + )} - {/* Branch pair is the widest and least essential chip, so it is capped and drops off first. */} {job.baseRef && job.headRef && ( - - {job.baseRef} โ† {job.headRef} - + <> + + + {job.baseRef} โ† {job.headRef} + + )} + - - + + {formatRelativeDate(job.createdAt)}
- -
- - - setStopOpen(true)} - /> - - {/* Always restarts the review from the beginning (every file), regardless of the job's current status. */} - - - setDeleteOpen(true)} - /> -
-
- -

{title}

-
-
{children}
-
- ); -} - -function DetailRow({ label, children }: { label: string; children: ReactNode }) { - return ( -
-
{label}
-
{children}
-
- ); -} - -function StepRow({ step }: { step: JobStep }) { - const isRunning = step.status === 'running'; - const isPending = step.status === 'pending'; - const elapsed = elapsedSec(step); - - return ( -
-
- - - {step.name} - -
- -
- {isRunning ? ( - Running - ) : elapsed ? ( - - {elapsed} - - ) : ( - - )} -
-
- ); -} - -export function JobMetaCards({ job }: JobMetaCardsProps) { - const isPartialReview = job.status === 'done' && job.errorMessage?.startsWith('Partial review:'); - const steps = job.steps ?? []; - const TriggerIcon = TRIGGER_ICON[job.trigger] ?? Zap; - - return ( -
- -
- - - - - - {job.verdict ? : } - - - - - {job.trigger} - - - - - - {(job.totalInputTokens + job.totalOutputTokens).toLocaleString()} - - - - - - {formatRelativeDate(job.createdAt)} - - - - {job.reviewId && ( - - - GitHub - - - )} - - {job.retryOfJobId && ( - - - {job.retryOfJobId.slice(0, 8)} - - - )} -
- - {job.errorMessage && ( -
-

- - {isPartialReview ? 'Partial review' : 'Error'} -

-

- {job.errorMessage} -

-
- )} -
- - - {steps.length === 0 ? ( -

No steps recorded yet.

- ) : ( - steps.map((step) => ) - )} -
-
- ); -} +import type { ReactNode } from 'react'; +import { AtSign, Info, ListChecks, RotateCcw, Zap } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { cn, formatPreciseDuration } from '@codraoss/ui/utils'; +import type { JobDetail, JobStep } from '@codraoss/schema'; +import { + EmptyValue, + JobStatusLine, + MetaChip, + StatusDot, + VerdictPill, +} from './job-chips'; +import { DETAIL_LABEL, DETAIL_ROW, formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; + +interface JobMetaCardsProps { + job: JobDetail; +} + +const TRIGGER_ICON = { + auto: Zap, + mention: AtSign, + retry: RotateCcw, +} as const; + +function elapsedSec(step: JobStep): string | null { + if (step.finishedAt && step.startedAt) { + const start = new Date(step.startedAt).getTime(); + const end = new Date(step.finishedAt).getTime(); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + return formatPreciseDuration(end - start); + } + return null; +} + +function MetaPanel({ + icon: Icon, + title, + children, +}: { + icon: typeof Info; + title: string; + children: ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ {/* Recessed inner panel, same as the dashboard stat cards. */} +
{children}
+
+ ); +} + +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function StepRow({ step }: { step: JobStep }) { + const isRunning = step.status === 'running'; + const isPending = step.status === 'pending'; + const elapsed = elapsedSec(step); + + return ( +
+
+ + + {step.name} + +
+ +
+ {isRunning ? ( + Running + ) : elapsed ? ( + + {elapsed} + + ) : ( + + )} +
+
+ ); +} + +export function JobMetaCards({ job }: JobMetaCardsProps) { + const steps = job.steps ?? []; + const TriggerIcon = TRIGGER_ICON[job.trigger] ?? Zap; + + return ( +
+ +
+ + + + + + {job.verdict ? : } + + + + + {job.trigger} + + + + + + {(job.totalInputTokens + job.totalOutputTokens).toLocaleString()} + + + + + + {formatRelativeDate(job.createdAt)} + + + + {job.retryOfJobId && ( + + + {job.retryOfJobId.slice(0, 8)} + + + )} +
+ +
+ + + {steps.length === 0 ? ( +

No steps recorded yet.

+ ) : ( + steps.map((step) => ) + )} +
+
+ ); +} diff --git a/src/client/components/features/job-detail/job-progress.tsx b/apps/dashboard/src/components/features/job-detail/job-progress.tsx similarity index 93% rename from src/client/components/features/job-detail/job-progress.tsx rename to apps/dashboard/src/components/features/job-detail/job-progress.tsx index 97e42fb5..da8f1f24 100644 --- a/src/client/components/features/job-detail/job-progress.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-progress.tsx @@ -24,8 +24,8 @@ export function JobProgress({ job }: JobProgressProps) { : null; return ( -
-
+
+
{isQueued ? @@ -40,7 +40,8 @@ export function JobProgress({ job }: JobProgressProps) {
-
+ {/* Recessed inner panel, same as the dashboard stat cards. */} +
-
+
+

Review overview

@@ -69,8 +69,9 @@ export function JobReviewOverview({ job }: JobReviewOverviewProps) {
- {/* Markdown's own leading/trailing block margins are zeroed so the card padding alone controls the gap. */} -
+ {/* Recessed inner panel (same as the dashboard stat cards); markdown's own leading/trailing + block margins are zeroed so the well padding alone controls the gap. */} +
{renderSummary()} @@ -78,7 +79,8 @@ export function JobReviewOverview({ job }: JobReviewOverviewProps) {
-
+ {/* Footer sits on the card face, mirroring the stat cards' delta row. */} +

Priority triage

{reviewSeverities.map((sev) => { diff --git a/src/client/components/features/job-detail/job-skeleton.tsx b/apps/dashboard/src/components/features/job-detail/job-skeleton.tsx similarity index 100% rename from src/client/components/features/job-detail/job-skeleton.tsx rename to apps/dashboard/src/components/features/job-detail/job-skeleton.tsx diff --git a/apps/dashboard/src/components/features/job-detail/job-status-notice.tsx b/apps/dashboard/src/components/features/job-detail/job-status-notice.tsx new file mode 100644 index 00000000..0c5975cf --- /dev/null +++ b/apps/dashboard/src/components/features/job-detail/job-status-notice.tsx @@ -0,0 +1,141 @@ +import { CircleSlash, History, OctagonAlert, TriangleAlert, type LucideIcon } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import type { JobDetail } from '@codraoss/schema'; + +interface JobStatusNoticeProps { + job: JobDetail; +} + +type Tone = 'danger' | 'warning' | 'neutral'; + +interface Notice { + tone: Tone; + icon: LucideIcon; + title: string; + /** Plain-language explanation of what happened. */ + hint: string; + /** Raw server message, shown as a mono block only when it adds something the hint doesn't. */ + detail?: string | null; +} + +// Icon tile + border tone per notice kind. Neutral outcomes (superseded/stopped) deliberately +// avoid red: nothing went wrong, the run just stopped mattering. +const TONE: Record = { + danger: { + tile: 'border-danger-border bg-danger-bg', + icon: 'text-danger', + detail: 'border-danger-border/60 bg-danger-bg text-danger', + }, + warning: { + tile: 'border-warning-border bg-warning-bg', + icon: 'text-warning', + detail: 'border-warning-border/60 bg-warning-bg text-warning', + }, + neutral: { + tile: 'border-ui-line bg-ui-fill/40', + icon: 'text-ui-default', + detail: 'border-ui-line ui-well text-ui-subtle', + }, +}; + +function describe(job: JobDetail): Notice | null { + const message = job.errorMessage?.trim() || null; + + if (job.status === 'done' && message?.startsWith('Partial review:')) { + return { + tone: 'warning', + icon: TriangleAlert, + title: 'Partial review', + hint: 'Codra posted a review, but not every file made it in.', + detail: message.replace(/^Partial review:\s*/, ''), + }; + } + + if (job.status === 'superseded') { + return { + tone: 'neutral', + icon: History, + title: 'Superseded', + hint: 'A newer commit or review took over this pull request before this run finished, so it was retired. The latest review for this PR has the current results.', + }; + } + + if (job.status === 'cancelled' || job.status === 'stopped') { + return { + tone: 'neutral', + icon: CircleSlash, + title: job.status === 'stopped' ? 'Review stopped' : 'Review cancelled', + hint: 'This run ended before it finished, so any files below are only the ones reviewed up to that point. Re-run it from the header to start over.', + detail: message, + }; + } + + if (job.status === 'failed') { + return { + tone: 'danger', + icon: OctagonAlert, + title: 'Review failed', + hint: 'Codra could not finish this review. Retry it from the header once the cause below is addressed.', + detail: message, + }; + } + + // Any other status that still carries a message (e.g. a recovered run) shouldn't swallow it. + if (message) { + return { + tone: 'danger', + icon: OctagonAlert, + title: 'Something went wrong', + hint: 'The run reported a problem:', + detail: message, + }; + } + + return null; +} + +/** + * Page-level banner for a run's terminal outcome, rather than a cramped box inside the Job + * details rows: failures read as failures, and superseded/stopped read as neutral facts. + */ +export function JobStatusNotice({ job }: JobStatusNoticeProps) { + const notice = describe(job); + if (!notice) return null; + + const { tone, icon: Icon, title, hint, detail } = notice; + const styles = TONE[tone]; + + return ( +
+
+ + + + +
+

{title}

+

{hint}

+ + {detail && ( +

+ {detail} +

+ )} +
+
+
+ ); +} diff --git a/src/client/components/features/job-detail/status-badge.tsx b/apps/dashboard/src/components/features/job-detail/status-badge.tsx similarity index 100% rename from src/client/components/features/job-detail/status-badge.tsx rename to apps/dashboard/src/components/features/job-detail/status-badge.tsx diff --git a/src/client/components/features/models/model-chain.tsx b/apps/dashboard/src/components/features/models/model-chain.tsx similarity index 100% rename from src/client/components/features/models/model-chain.tsx rename to apps/dashboard/src/components/features/models/model-chain.tsx diff --git a/src/client/components/features/models/model-route.ts b/apps/dashboard/src/components/features/models/model-route.ts similarity index 100% rename from src/client/components/features/models/model-route.ts rename to apps/dashboard/src/components/features/models/model-route.ts diff --git a/src/client/components/features/repos/repo-model-modal.tsx b/apps/dashboard/src/components/features/repos/repo-model-modal.tsx similarity index 100% rename from src/client/components/features/repos/repo-model-modal.tsx rename to apps/dashboard/src/components/features/repos/repo-model-modal.tsx diff --git a/src/client/components/features/repos/repo-route.ts b/apps/dashboard/src/components/features/repos/repo-route.ts similarity index 100% rename from src/client/components/features/repos/repo-route.ts rename to apps/dashboard/src/components/features/repos/repo-route.ts diff --git a/src/client/components/features/repos/repo-row.tsx b/apps/dashboard/src/components/features/repos/repo-row.tsx similarity index 100% rename from src/client/components/features/repos/repo-row.tsx rename to apps/dashboard/src/components/features/repos/repo-row.tsx diff --git a/src/client/components/features/reviews/live-review-stepper.tsx b/apps/dashboard/src/components/features/reviews/live-review-stepper.tsx similarity index 100% rename from src/client/components/features/reviews/live-review-stepper.tsx rename to apps/dashboard/src/components/features/reviews/live-review-stepper.tsx diff --git a/src/client/components/features/settings/about-section.tsx b/apps/dashboard/src/components/features/settings/about-section.tsx similarity index 97% rename from src/client/components/features/settings/about-section.tsx rename to apps/dashboard/src/components/features/settings/about-section.tsx index 9e9e32d0..65b15b61 100644 --- a/src/client/components/features/settings/about-section.tsx +++ b/apps/dashboard/src/components/features/settings/about-section.tsx @@ -1,5 +1,5 @@ import { Badge, LayerCard, SectionCard, Text } from '@codraoss/ui'; -import pkg from '../../../../../package.json'; +import pkg from '../../../../../../package.json'; import { ExternalLink } from 'lucide-react'; // No props and no state, which is why this is a component rather than inlined JSX: it keeps 50 lines of markup out of SettingsPage. diff --git a/src/client/components/features/settings/default-models-section.tsx b/apps/dashboard/src/components/features/settings/default-models-section.tsx similarity index 100% rename from src/client/components/features/settings/default-models-section.tsx rename to apps/dashboard/src/components/features/settings/default-models-section.tsx diff --git a/src/client/components/features/settings/field-label.tsx b/apps/dashboard/src/components/features/settings/field-label.tsx similarity index 100% rename from src/client/components/features/settings/field-label.tsx rename to apps/dashboard/src/components/features/settings/field-label.tsx diff --git a/src/client/components/features/settings/new-provider-form.tsx b/apps/dashboard/src/components/features/settings/new-provider-form.tsx similarity index 100% rename from src/client/components/features/settings/new-provider-form.tsx rename to apps/dashboard/src/components/features/settings/new-provider-form.tsx diff --git a/src/client/components/features/settings/provider-list.tsx b/apps/dashboard/src/components/features/settings/provider-list.tsx similarity index 100% rename from src/client/components/features/settings/provider-list.tsx rename to apps/dashboard/src/components/features/settings/provider-list.tsx diff --git a/src/client/components/features/settings/provider-row.tsx b/apps/dashboard/src/components/features/settings/provider-row.tsx similarity index 100% rename from src/client/components/features/settings/provider-row.tsx rename to apps/dashboard/src/components/features/settings/provider-row.tsx diff --git a/src/client/components/features/settings/review-section.tsx b/apps/dashboard/src/components/features/settings/review-section.tsx similarity index 100% rename from src/client/components/features/settings/review-section.tsx rename to apps/dashboard/src/components/features/settings/review-section.tsx diff --git a/src/client/components/features/settings/settings-support.ts b/apps/dashboard/src/components/features/settings/settings-support.ts similarity index 100% rename from src/client/components/features/settings/settings-support.ts rename to apps/dashboard/src/components/features/settings/settings-support.ts diff --git a/src/client/components/features/stats/chart-primitives.tsx b/apps/dashboard/src/components/features/stats/chart-primitives.tsx similarity index 69% rename from src/client/components/features/stats/chart-primitives.tsx rename to apps/dashboard/src/components/features/stats/chart-primitives.tsx index 718d50c8..1264b3ee 100644 --- a/src/client/components/features/stats/chart-primitives.tsx +++ b/apps/dashboard/src/components/features/stats/chart-primitives.tsx @@ -1,9 +1,22 @@ -import { Skeleton, GraphShell } from '@codraoss/ui'; +import { Skeleton, GraphShell, SeriesMarker, type SeriesMarkerProps } from '@codraoss/ui'; import type { ReactNode } from 'react'; import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; import { formatCompact, formatDayRange } from './chart-support'; -export function ChartTooltip({ active, payload, label }: any) { +/** Per-`dataKey` marker description, so the tooltip can draw exactly what the legend drew. */ +export type SeriesMarkers = Record; + +/** + * Recharts reports a series' raw `fill`, so gradient- and pattern-backed bars arrive as `url(#id)`, + * which is not a CSS colour - assigning it to `background-color` renders nothing at all. The + * caller's `markers` map is the source of truth; this only covers series it doesn't describe. + */ +function fallbackMarker(color: string | undefined): SeriesMarkerProps { + if (!color || color.startsWith('url(')) return { color: 'currentColor' }; + return { color }; +} + +export function ChartTooltip({ active, payload, label, markers }: any) { if (!active || !payload?.length) return null; const endDay: string | undefined = payload[0]?.payload?.endDay; @@ -16,9 +29,9 @@ export function ChartTooltip({ active, payload, label }: any) {
{payload.map((item: any) => (
- {item.name} @@ -34,7 +47,7 @@ export function ChartTooltip({ active, payload, label }: any) { function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { return ( -
+
@@ -44,7 +57,7 @@ function GraphCardSkeleton({ title, icon, className = '' }: { title: string; ico function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) { return ( -
+
{Array.from({ length: rows }).map((_, i) => (
@@ -57,9 +70,10 @@ function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title ); } +/** Rows only - `MetricsGrid` owns the outer wrapper so the skeleton/chart handoff isn't animated. */ export function MetricsGridSkeleton() { return ( -
+ <>
} /> } /> @@ -69,6 +83,6 @@ export function MetricsGridSkeleton() { } rows={4} /> } rows={5} />
-
+ ); } diff --git a/src/client/components/features/stats/chart-support.ts b/apps/dashboard/src/components/features/stats/chart-support.ts similarity index 100% rename from src/client/components/features/stats/chart-support.ts rename to apps/dashboard/src/components/features/stats/chart-support.ts diff --git a/src/client/components/features/stats/metrics-grid-charts.tsx b/apps/dashboard/src/components/features/stats/metrics-grid-charts.tsx similarity index 87% rename from src/client/components/features/stats/metrics-grid-charts.tsx rename to apps/dashboard/src/components/features/stats/metrics-grid-charts.tsx index 1fbea113..a8c8d40c 100644 --- a/src/client/components/features/stats/metrics-grid-charts.tsx +++ b/apps/dashboard/src/components/features/stats/metrics-grid-charts.tsx @@ -15,7 +15,8 @@ import { import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; import type { StatsPayload } from '@codraoss/schema'; import { - ChartTooltip + ChartTooltip, + type SeriesMarkers } from './chart-primitives'; import { GraphShell, @@ -85,28 +86,40 @@ export function MetricsGridCharts({ }; const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); + // One description per series, feeding both the legend chip and the tooltip swatch, so the two + // can't drift apart. Keyed by `dataKey`, which is what Recharts reports back on hover. + const flowMarkers: SeriesMarkers = { + jobs: { color: amber }, + comments: { color: dashColor, dashed: true }, + }; + const tokenMarkers: SeriesMarkers = { + outputTokens: { color: CHART.blue }, + inputTokens: { hatched: true }, + }; + return ( -
+ // Rows only: `MetricsGrid` owns the outer wrapper. See the note there. + <>
} legend={ <> - - + + {bucketNote} } > -
+
- } cursor={{ stroke: amber, strokeDasharray: '4 4' }} /> + } cursor={{ stroke: amber, strokeDasharray: '4 4' }} /> } legend={ <> - - + + {bucketNote} } > -
+
- } cursor={{ fill: cursorColor }} /> + } cursor={{ fill: cursorColor }} /> {/* Capped so a short range (or a heavily bucketed one) doesn't render a handful of slab-wide bars. */} @@ -163,7 +176,7 @@ export function MetricsGridCharts({
}> -
+
@@ -242,6 +255,6 @@ export function MetricsGridCharts({
-
+ ); } diff --git a/apps/dashboard/src/components/features/stats/metrics-grid-prefetch.ts b/apps/dashboard/src/components/features/stats/metrics-grid-prefetch.ts new file mode 100644 index 00000000..c05a542f --- /dev/null +++ b/apps/dashboard/src/components/features/stats/metrics-grid-prefetch.ts @@ -0,0 +1,32 @@ +// The chart chunk is only *rendered* once stats have loaded, so importing it lazily at render time +// would delay its download until after the fetch resolved -- a waterfall the eager import it +// replaced never had. Calling the prefetch on mount puts the ~68 kB gzip request alongside the +// stats fetch instead of behind it. +// Separate from metrics-grid.tsx so that file keeps exporting components only (Fast Refresh). +import type { MetricsGridCharts } from './metrics-grid-charts'; + +type ChartsComponent = typeof MetricsGridCharts; + +let pending: Promise | null = null; +let resolved: ChartsComponent | null = null; + +/** Memoized so the prefetch and the render path share one request and one module instance. */ +export function loadMetricsCharts(): Promise { + pending ??= import('./metrics-grid-charts').then((m) => { + resolved = m.MetricsGridCharts; + return resolved; + }); + return pending; +} + +/** + * The already-loaded component, or null. Lets the grid render charts on the very first commit of a + * later visit, with no fallback frame in between. + */ +export function metricsChartsIfLoaded(): ChartsComponent | null { + return resolved; +} + +export function prefetchMetricsCharts() { + void loadMetricsCharts(); +} diff --git a/apps/dashboard/src/components/features/stats/metrics-grid.tsx b/apps/dashboard/src/components/features/stats/metrics-grid.tsx new file mode 100644 index 00000000..e9e2cc46 --- /dev/null +++ b/apps/dashboard/src/components/features/stats/metrics-grid.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import type { StatsPayload } from '@codraoss/schema'; +import { MetricsGridSkeleton } from './chart-primitives'; +import { loadMetricsCharts, metricsChartsIfLoaded } from './metrics-grid-prefetch'; + +/** + * Owns the whole loading state - the chart chunk *and* the data - so the skeleton is one element in + * one tree position for the entire wait. + * + * This deliberately avoids `lazy` + `Suspense`: with a fallback, the skeleton renders from a second + * position, so the handoff between "no data yet" and "chunk still downloading" unmounts one + * skeleton and mounts another. Identical markup, but React sees a new element - restarting the + * shimmer and replaying the parent's `page-enter` fade-up, which reads as the cards refreshing + * twice before any content arrives. + */ +export function MetricsGrid({ + stats, + isDark, +}: { + stats: StatsPayload | null; + isDark: boolean; +}) { + const [Charts, setCharts] = useState>( + metricsChartsIfLoaded, + ); + + useEffect(() => { + if (Charts) return; + let active = true; + // Component values are functions, so the updater has to return one rather than be one. + void loadMetricsCharts().then((loaded) => { + if (active) setCharts(() => loaded); + }); + return () => { + active = false; + }; + }, [Charts]); + + // The wrapper is what `page-enter` animates (it's the section's direct child), so it stays + // mounted across the handoff: the skeleton fades up once, then the real cards simply replace it + // in place. Returning the skeleton and the charts as siblings-of-different-shape would mount a + // new direct child and replay the fade-up, which read as the page animating twice. + return ( +
+ {!Charts || !stats ? : } +
+ ); +} diff --git a/src/client/components/features/stats/overview-stats.tsx b/apps/dashboard/src/components/features/stats/overview-stats.tsx similarity index 100% rename from src/client/components/features/stats/overview-stats.tsx rename to apps/dashboard/src/components/features/stats/overview-stats.tsx diff --git a/src/client/components/features/stats/stats-grid.tsx b/apps/dashboard/src/components/features/stats/stats-grid.tsx similarity index 91% rename from src/client/components/features/stats/stats-grid.tsx rename to apps/dashboard/src/components/features/stats/stats-grid.tsx index 8b8db681..cb73a0e2 100644 --- a/src/client/components/features/stats/stats-grid.tsx +++ b/apps/dashboard/src/components/features/stats/stats-grid.tsx @@ -94,7 +94,9 @@ function StatFooter({ }) { if (loading) { return ( -
+ // h-7 == pt-3 + the loaded row's 16px text-xs line box. Without it the card is 4px shorter + // while loading, shifting everything below it (and the dashboard's row-fitting measurement). +
@@ -112,7 +114,7 @@ function StatFooter({ const label = flat ? `${prefix} unchanged` : `${prefix} ${up ? 'Increased' : 'Decreased'} by`; return ( -
+
{label} {!flat && ( diff --git a/src/client/components/features/stats/time-range-select.tsx b/apps/dashboard/src/components/features/stats/time-range-select.tsx similarity index 100% rename from src/client/components/features/stats/time-range-select.tsx rename to apps/dashboard/src/components/features/stats/time-range-select.tsx diff --git a/src/client/components/layout/account-menu.tsx b/apps/dashboard/src/components/layout/account-menu.tsx similarity index 52% rename from src/client/components/layout/account-menu.tsx rename to apps/dashboard/src/components/layout/account-menu.tsx index 4c939422..df91406c 100644 --- a/src/client/components/layout/account-menu.tsx +++ b/apps/dashboard/src/components/layout/account-menu.tsx @@ -2,10 +2,51 @@ import { GithubMark } from '@codraoss/ui'; import { Link } from 'react-router-dom'; import { useEffect, useRef, useState } from 'react'; import { api } from '@client/lib/api'; -import { LogOut, ChevronsUpDown, UserRound } from 'lucide-react'; +import { ArrowUpRight, LogOut, ChevronsUpDown, UserRound } from 'lucide-react'; import { cn } from '@codraoss/ui/utils'; import type { AuthSessionUser } from '@codraoss/schema/api'; +/** Shared by the pill trigger and the menu's identity header. */ +function Avatar({ + user, + initial, + size, +}: { + user: AuthSessionUser; + initial: string; + size: number; +}) { + const box = { width: size, height: size }; + + if (user.avatarUrl) { + return ( + + ); + } + + return ( + + {initial} + + ); +} + +/** One row in the menu: icon, label, and an optional trailing affordance. */ +const ITEM = cn( + 'group/item flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-[13px] font-medium', + 'text-ui-default outline-none transition-colors duration-150', +); + +const ITEM_ICON = 'shrink-0 text-ui-subtle transition-colors group-hover/item:text-ui-default'; + /** * Built from scratch (no shared dropdown primitive): a local popover anchored * to the account row via `absolute bottom-full`, so it opens directly above @@ -41,7 +82,9 @@ export function AccountMenu({ user }: { user: AuthSessionUser }) { return (
- {/* Identity lives in the trigger below, so the panel is purely actions; it stays mounted and animates via CSS, and is `invisible` + `pointer-events-none` when closed so it can't sit on top of rows behind it and swallow clicks. */} + {/* Repeats the identity as the panel's header so the menu has a subject of its own. Stays + mounted and animates via CSS, and is `invisible` + `pointer-events-none` when closed so it + can't sit on top of rows behind it and swallow clicks. */}
+
+ + + + {name} + + + @{user.login} + + +
+ +
+ setOpen(false)} > - + Account @@ -79,14 +135,18 @@ export function AccountMenu({ user }: { user: AuthSessionUser }) { rel="noopener noreferrer" tabIndex={open ? 0 : -1} className={cn( - 'group/item flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-[13px] font-medium text-ui-default', - 'outline-none transition-colors duration-150', + ITEM, 'hover:bg-ui-fill hover:text-ui-strong focus-visible:bg-ui-fill focus-visible:text-ui-strong', )} onClick={() => setOpen(false)} > - + GitHub profile + {/* Marks the one item that leaves the app. */} +
@@ -96,8 +156,7 @@ export function AccountMenu({ user }: { user: AuthSessionUser }) { type="button" tabIndex={open ? 0 : -1} className={cn( - 'group/item flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-[13px] font-medium text-ui-default', - 'outline-none transition-colors duration-150', + ITEM, 'hover:bg-danger-bg hover:text-danger focus-visible:bg-danger-bg focus-visible:text-danger', )} onClick={async () => { @@ -106,39 +165,49 @@ export function AccountMenu({ user }: { user: AuthSessionUser }) { location.href = '/login'; }} > - + Log out
+ {/* Avatar, name over handle, and the double chevron. Geometry (full width, radius, spacing) + matches the sidebar rows above it. */}
); diff --git a/src/client/components/layout/app-shell.tsx b/apps/dashboard/src/components/layout/app-shell.tsx similarity index 76% rename from src/client/components/layout/app-shell.tsx rename to apps/dashboard/src/components/layout/app-shell.tsx index 73b52e32..fd88e91b 100644 --- a/src/client/components/layout/app-shell.tsx +++ b/apps/dashboard/src/components/layout/app-shell.tsx @@ -1,39 +1,61 @@ import { Outlet, Link } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { SharedLayoutBg } from '@codraoss/ui/motion'; -import { api } from '@client/lib/api'; -import { LayoutDashboard, AlignLeft, GitBranch, BarChart2, Sun, Moon, Activity, Settings, Star, X, ArrowUpRight } from 'lucide-react'; +import { AlignLeft, Sun, Moon, Star, X, ArrowUpRight } from 'lucide-react'; import { cn } from '@codraoss/ui/utils'; import { useTheme } from '@codraoss/ui/theme'; import codraDark from '@/assets/codra-fullicon-dark.svg'; import codraLight from '@/assets/codra-fullicon-light.svg'; -import type { AuthSessionUser } from '@codraoss/schema/api'; - import { SidebarNavItem } from '@client/components/layout/sidebar-nav-item'; import { AccountMenu } from '@client/components/layout/account-menu'; -const links = [ - { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, end: true }, - { to: '/jobs', label: 'Jobs', icon: Activity, end: false }, - { to: '/repos', label: 'Repos', icon: GitBranch, end: false }, - { to: '/stats', label: 'Stats', icon: BarChart2, end: false }, - { to: '/settings', label: 'Settings', icon: Settings, end: false }, -]; +import { navItems as defaultNavItems } from '@client/nav'; +import type { NavItem } from '@client/nav'; +import { SessionProvider, useSession } from '@client/hooks/use-session'; +import { useCan } from '@client/hooks/use-can'; + +export function AppShell({ navItems = defaultNavItems }: { navItems?: NavItem[] } = {}) { + return ( + + + + ); +} + +function SidebarNav({ navItems, onNavigate }: { navItems: NavItem[]; onNavigate: () => void }) { + return ( + <> + {navItems.map(({ to, label, end, icon, requiresAction }) => ( + + ))} + + ); +} +function NavEntry({ to, label, end, icon, requiresAction, onNavigate }: NavItem & { onNavigate: () => void }) { + const allowed = useCan(requiresAction ?? '*'); + if (requiresAction && !allowed) return null; -export function AppShell() { + return ( + /* SharedLayoutBg clones this div to inject pill + z-10 wrapper. */ +
+ +
+ ); +} + +function AppShellInner({ navItems }: { navItems: NavItem[] }) { const { theme, toggleTheme } = useTheme(); - const [sessionUser, setSessionUser] = useState(null); + const { user: sessionUser } = useSession(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - useEffect(() => { - let cancelled = false; - api.getSession() - .then(r => { if (!cancelled) setSessionUser(r.user); }) - .catch(() => { if (!cancelled) setSessionUser(null); }); - return () => { cancelled = true; }; - }, []); - - // Scroll doesn't bubble: listen in capture phase, flag scrolled el with data-scrolling for CSS, clear after 700ms idle. useEffect(() => { const timers = new WeakMap(); const onScroll = (e: Event) => { @@ -54,7 +76,6 @@ export function AppShell() {
{mobileMenuOpen && ( - /* Hidden from a11y tree: drawer's X is the real focusable close; scrim as a tab stop would double-announce. */
- {/* Shell never scrolls; card fills viewport, pages scroll their own body inside it. */}
- {/* Full-width so scrollbar sits at card's inner edge; short pages scroll here, always inside the card, never the window. */} -
+ {/* `scrollbar-gutter: stable` keeps the gutter reserved either way; otherwise gaining a scrollbar narrows the content, rewrapping text and shifting every measurement taken against this box. */} +
diff --git a/src/client/components/layout/page-header.tsx b/apps/dashboard/src/components/layout/page-header.tsx similarity index 100% rename from src/client/components/layout/page-header.tsx rename to apps/dashboard/src/components/layout/page-header.tsx diff --git a/src/client/components/layout/sidebar-nav-item.tsx b/apps/dashboard/src/components/layout/sidebar-nav-item.tsx similarity index 100% rename from src/client/components/layout/sidebar-nav-item.tsx rename to apps/dashboard/src/components/layout/sidebar-nav-item.tsx diff --git a/src/client/components/shared/jobs-table.tsx b/apps/dashboard/src/components/shared/jobs-table.tsx similarity index 96% rename from src/client/components/shared/jobs-table.tsx rename to apps/dashboard/src/components/shared/jobs-table.tsx index d1a92e5e..00cd5902 100644 --- a/src/client/components/shared/jobs-table.tsx +++ b/apps/dashboard/src/components/shared/jobs-table.tsx @@ -1,313 +1,315 @@ import { Skeleton } from '@codraoss/ui'; -import { Link } from 'react-router-dom'; -import { FolderGit2, GitCommitHorizontal, GitPullRequest } from 'lucide-react'; - -import { VerdictPill, MetaChip, AuthorAvatar } from '@client/components/features/job-detail/job-chips'; -import { cn } from '@codraoss/ui/utils'; -import { formatDateTime } from '@client/lib/timezone'; -import { STATUS_DOT, formatRelativeDate, jobDuration, statusLabel } from '@client/lib/job-format'; - -import type { JobSummary } from '@codraoss/schema'; - -type Column = - | 'title' - | 'status' - | 'verdict' - | 'repo' - | 'commit' - | 'pr' - | 'updated' - | 'author'; - -interface JobsTableProps { - jobs: JobSummary[]; - loading: boolean; - /** Columns to show. Defaults to all. */ - columns?: Column[]; - /** Fill the parent's height and scroll the body internally, instead of growing to fit all rows. */ - fill?: boolean; -} - -const DEFAULT_COLUMNS: Column[] = [ - 'title', - 'status', - 'verdict', - 'repo', - 'commit', - 'pr', - 'updated', - 'author', -]; - -/* Title takes all the slack; secondary metadata drops off first on narrow viewports so a row - never wraps and the title never collapses to nothing. */ -const COLUMN_CLASSES: Record = { - title: 'min-w-0 pl-4', - status: 'w-[156px]', - verdict: 'hidden xl:table-cell w-[108px]', - repo: 'hidden md:table-cell w-[176px]', - commit: 'hidden 2xl:table-cell w-[96px]', - pr: 'hidden xl:table-cell w-[76px]', - updated: 'w-[84px]', - author: 'w-12 pr-4', -}; - -function formatDate(value: JobSummary['createdAt']) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ''; - // Rendered in the account's display time zone (falls back to the browser's). - return formatDateTime(date, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -function StatusCell({ job }: { job: JobSummary }) { - const duration = jobDuration(job); - const isRunning = job.status === 'running'; - - return ( - - - - {statusLabel(job.status)} - - {duration && ( - - {duration} - - )} - - ); -} - -function JobMobileCard({ job }: { job: JobSummary }) { - return ( - -
-

- {job.prTitle ?? 'Untitled PR'} -

- - {formatRelativeDate(job.createdAt)} - -
- -
- - {job.verdict && } -
- -
- - {job.owner}/{job.repo} - - #{job.prNumber} -
- - ); -} - -/* Fixed cell height, not vertical padding: padding-based rows grew ~6px on verdict-pill rows and - broke the vertical rhythm. */ -const CELL = 'h-12 border-t border-ui-line px-2.5 align-middle'; - -/* Top border goes transparent, not 0-width, so it can't double up with whatever sits above the - table without changing row height. */ -const ROW_DIVIDERS = 'first:[&>td]:border-transparent'; - -export function JobsTable({ jobs, loading, columns, fill = false }: JobsTableProps) { - const cols: Column[] = columns ?? DEFAULT_COLUMNS; - const show = (column: Column) => cols.includes(column); - - return ( -
-
- {loading && jobs.length === 0 - ? Array.from({ length: 6 }).map((_, i) => ( -
- -
- - -
-
- - -
-
- )) - : jobs.map((job) => )} -
- -
- - - {loading && jobs.length === 0 - ? Array.from({ length: 8 }).map((_, i) => ( - - {show('title') && ( - - )} - {show('status') && ( - - )} - {show('verdict') && ( - - )} - {show('repo') && ( - - )} - {show('commit') && ( - - )} - {show('pr') && ( - - )} - {show('updated') && ( - - )} - {show('author') && ( - - )} - - )) - : jobs.map((job) => ( - - {show('title') && ( - - )} - - {show('status') && ( - - )} - - {show('verdict') && ( - - )} - - {show('repo') && ( - - )} - - {show('commit') && ( - - )} - - {show('pr') && ( - - )} - - {show('updated') && ( - - )} - - {show('author') && ( - - )} - - ))} - -
- - - - - - - - - - - - - - - - - - - - - - - -
- {/* `after:` stretches this link across the row, making the whole row one click target. */} - - {job.prTitle ?? 'Untitled PR'} - - - - - {job.verdict && } - - - {job.owner}/{job.repo} - - - {job.commitSha ? ( - - {job.commitSha.slice(0, 7)} - - ) : ( - - - )} - - - #{job.prNumber} - - - - {formatRelativeDate(job.createdAt)} - - - - - -
-
-
- ); -} +import { Link } from 'react-router-dom'; +import { FolderGit2, GitCommitHorizontal, GitPullRequest } from 'lucide-react'; + +import { VerdictPill, MetaChip, AuthorAvatar } from '@client/components/features/job-detail/job-chips'; +import { cn } from '@codraoss/ui/utils'; +import { formatDateTime } from '@client/lib/timezone'; +import { STATUS_DOT, formatRelativeDate, jobDuration, statusLabel } from '@client/lib/job-format'; + +import type { JobSummary } from '@codraoss/schema'; + +type Column = + | 'title' + | 'status' + | 'verdict' + | 'repo' + | 'commit' + | 'pr' + | 'updated' + | 'author'; + +interface JobsTableProps { + jobs: JobSummary[]; + loading: boolean; + /** Columns to show. Defaults to all. */ + columns?: Column[]; + /** Fill the parent's height and scroll the body internally, instead of growing to fit all rows. */ + fill?: boolean; + /** Placeholder rows drawn while loading. Match the expected result count to avoid a layout jump. */ + skeletonRows?: number; +} + +const DEFAULT_COLUMNS: Column[] = [ + 'title', + 'status', + 'verdict', + 'repo', + 'commit', + 'pr', + 'updated', + 'author', +]; + +/* Title takes all the slack; secondary metadata drops off first on narrow viewports so a row + never wraps and the title never collapses to nothing. */ +const COLUMN_CLASSES: Record = { + title: 'min-w-0 pl-4', + status: 'w-[156px]', + verdict: 'hidden xl:table-cell w-[108px]', + repo: 'hidden md:table-cell w-[176px]', + commit: 'hidden 2xl:table-cell w-[96px]', + pr: 'hidden xl:table-cell w-[76px]', + updated: 'w-[84px]', + author: 'w-12 pr-4', +}; + +function formatDate(value: JobSummary['createdAt']) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + // Rendered in the account's display time zone (falls back to the browser's). + return formatDateTime(date, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function StatusCell({ job }: { job: JobSummary }) { + const duration = jobDuration(job); + const isRunning = job.status === 'running'; + + return ( + + + + {statusLabel(job.status)} + + {duration && ( + + {duration} + + )} + + ); +} + +function JobMobileCard({ job }: { job: JobSummary }) { + return ( + +
+

+ {job.prTitle ?? 'Untitled PR'} +

+ + {formatRelativeDate(job.createdAt)} + +
+ +
+ + {job.verdict && } +
+ +
+ + {job.owner}/{job.repo} + + #{job.prNumber} +
+ + ); +} + +/* Fixed cell height, not vertical padding: padding-based rows grew ~6px on verdict-pill rows and + broke the vertical rhythm. */ +const CELL = 'h-12 border-t border-ui-line px-2.5 align-middle'; + +/* Top border goes transparent, not 0-width, so it can't double up with whatever sits above the + table without changing row height. */ +const ROW_DIVIDERS = 'first:[&>td]:border-transparent'; + +export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows }: JobsTableProps) { + const cols: Column[] = columns ?? DEFAULT_COLUMNS; + const show = (column: Column) => cols.includes(column); + + return ( +
+
+ {loading && jobs.length === 0 + ? Array.from({ length: skeletonRows ?? 6 }).map((_, i) => ( +
+ +
+ + +
+
+ + +
+
+ )) + : jobs.map((job) => )} +
+ +
+ + + {loading && jobs.length === 0 + ? Array.from({ length: skeletonRows ?? 8 }).map((_, i) => ( + + {show('title') && ( + + )} + {show('status') && ( + + )} + {show('verdict') && ( + + )} + {show('repo') && ( + + )} + {show('commit') && ( + + )} + {show('pr') && ( + + )} + {show('updated') && ( + + )} + {show('author') && ( + + )} + + )) + : jobs.map((job) => ( + + {show('title') && ( + + )} + + {show('status') && ( + + )} + + {show('verdict') && ( + + )} + + {show('repo') && ( + + )} + + {show('commit') && ( + + )} + + {show('pr') && ( + + )} + + {show('updated') && ( + + )} + + {show('author') && ( + + )} + + ))} + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {/* `after:` stretches this link across the row, making the whole row one click target. */} + + {job.prTitle ?? 'Untitled PR'} + + + + + {job.verdict && } + + + {job.owner}/{job.repo} + + + {job.commitSha ? ( + + {job.commitSha.slice(0, 7)} + + ) : ( + - + )} + + + #{job.prNumber} + + + + {formatRelativeDate(job.createdAt)} + + + + + +
+
+
+ ); +} diff --git a/src/client/components/shared/page-header-actions.tsx b/apps/dashboard/src/components/shared/page-header-actions.tsx similarity index 100% rename from src/client/components/shared/page-header-actions.tsx rename to apps/dashboard/src/components/shared/page-header-actions.tsx diff --git a/src/client/components/shared/route-error-boundary.tsx b/apps/dashboard/src/components/shared/route-error-boundary.tsx similarity index 100% rename from src/client/components/shared/route-error-boundary.tsx rename to apps/dashboard/src/components/shared/route-error-boundary.tsx diff --git a/apps/dashboard/src/hooks/use-can.ts b/apps/dashboard/src/hooks/use-can.ts new file mode 100644 index 00000000..af8bf631 --- /dev/null +++ b/apps/dashboard/src/hooks/use-can.ts @@ -0,0 +1,9 @@ +import type { ApiAction } from '@codraoss/schema/api'; +import { useSession } from '@client/hooks/use-session'; + +// UI-side gate only; the server authorizes every request independently, and absent permissions mean nothing is restricted, so everything is allowed. +export function useCan(action: ApiAction): boolean { + const { permissions } = useSession(); + if (permissions === undefined) return true; + return permissions.includes('*') || permissions.includes(action); +} diff --git a/apps/dashboard/src/hooks/use-fit-rows.ts b/apps/dashboard/src/hooks/use-fit-rows.ts new file mode 100644 index 00000000..099d2187 --- /dev/null +++ b/apps/dashboard/src/hooks/use-fit-rows.ts @@ -0,0 +1,140 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +interface FitRowsOptions { + /** Height of one desktop table row, in px. Matches the `h-12` cell in JobsTable. */ + rowHeight?: number; + /** Height of one stacked mobile card, in px. */ + mobileRowHeight?: number; + /** Never ask for fewer than this many rows. */ + min?: number; + /** Never ask for more than this many rows (the API caps `limit` at 100). */ + max?: number; + /** + * Space left below the last row: the page wrapper's bottom padding (`py-8` = 32px) plus the + * panel border, so the table stops short of the scroll container instead of overflowing it. + */ + reserve?: number; +} + +/** Nearest scrollable ancestor, so the measurement is taken against the box the table lives in. */ +function scrollParent(el: HTMLElement): HTMLElement { + let node = el.parentElement; + while (node) { + const { overflowY } = getComputedStyle(node); + if (overflowY === 'auto' || overflowY === 'scroll') return node; + node = node.parentElement; + } + return document.documentElement; +} + +/** + * Every element laid out above `el` inside the scroller: its previous siblings, then its ancestors' + * previous siblings. Deliberately excludes `el`, its ancestors and its descendants - those contain + * the table, whose height is this hook's output, and observing them fed the row count back into + * itself. What is above `el` moves its top edge, so it genuinely needs a re-measure. + */ +function elementsAbove(el: HTMLElement, scroller: HTMLElement): Element[] { + const found: Element[] = []; + let node: HTMLElement | null = el; + + while (node && node !== scroller) { + for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) { + found.push(sib); + } + node = node.parentElement; + } + + return found; +} + +/** + * Fraction of a row a measurement has to clear before the count changes. A few px of layout jitter + * (a scrollbar appearing, a label rewrapping) would otherwise flip `rows` back and forth, and every + * flip refetches at a new `limit`. + */ +const DEADBAND = 0.35; + +/** + * Measures how many rows fit between the returned ref's top edge and the bottom of the scroll + * container, so a list can request exactly as many items as the viewport can show. + * + * `rows` is `null` until the first measurement lands - callers should hold off fetching until then + * so they don't fire one request at a guessed size and a second at the real one. + */ +export function useFitRows({ + rowHeight = 48, + mobileRowHeight = 101, + min = 3, + max = 30, + reserve = 36, +}: FitRowsOptions = {}) { + const ref = useRef(null); + const [rows, setRows] = useState(null); + + const measure = useCallback(() => { + const el = ref.current; + if (!el) return; + + const scroller = scrollParent(el); + // Offset from the scroll container's content top, not the viewport: stays put while the user + // scrolls, so growing the table can't feed back into the row count. + const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop; + const available = scroller.clientHeight - top - reserve; + + const wide = typeof window.matchMedia === 'function' + ? window.matchMedia('(min-width: 640px)').matches + : true; + const unit = wide ? rowHeight : mobileRowHeight; + const fits = Math.max(min, Math.min(max, Math.floor(available / unit))); + + setRows((current) => { + if (current === null || fits === current) return fits; + + // A one-row change has to be decisive; anything larger is a real resize, so take it as-is. + if (Math.abs(fits - current) === 1) { + const margin = unit * DEADBAND; + const growing = fits > current; + if (growing && available < (current + 1) * unit + margin) return current; + if (!growing && available > current * unit - margin) return current; + } + + return fits; + }); + }, [rowHeight, mobileRowHeight, min, max, reserve]); + + useLayoutEffect(() => { + measure(); + + const el = ref.current; + if (!el) return; + + // Guarded for jsdom, which has no ResizeObserver; window resize alone is enough there. + // + // Callbacks are coalesced into a frame so a burst of resize notifications measures once, after + // layout has settled. + let frame = 0; + const schedule = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + measure(); + }); + }; + + const scroller = scrollParent(el); + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null; + observer?.observe(scroller); + // Content above shifts the table's top edge: the stat cards settling, or a banner that only + // appears once its own request resolves. + for (const node of elementsAbove(el, scroller)) observer?.observe(node); + + window.addEventListener('resize', schedule); + return () => { + if (frame) cancelAnimationFrame(frame); + observer?.disconnect(); + window.removeEventListener('resize', schedule); + }; + }, [measure]); + + return { ref, rows }; +} diff --git a/src/client/hooks/use-job-detail.ts b/apps/dashboard/src/hooks/use-job-detail.ts similarity index 100% rename from src/client/hooks/use-job-detail.ts rename to apps/dashboard/src/hooks/use-job-detail.ts diff --git a/src/client/hooks/use-polling.ts b/apps/dashboard/src/hooks/use-polling.ts similarity index 100% rename from src/client/hooks/use-polling.ts rename to apps/dashboard/src/hooks/use-polling.ts diff --git a/src/client/hooks/use-provider-settings.ts b/apps/dashboard/src/hooks/use-provider-settings.ts similarity index 100% rename from src/client/hooks/use-provider-settings.ts rename to apps/dashboard/src/hooks/use-provider-settings.ts diff --git a/src/client/hooks/use-review-settings.ts b/apps/dashboard/src/hooks/use-review-settings.ts similarity index 100% rename from src/client/hooks/use-review-settings.ts rename to apps/dashboard/src/hooks/use-review-settings.ts diff --git a/apps/dashboard/src/hooks/use-session.tsx b/apps/dashboard/src/hooks/use-session.tsx new file mode 100644 index 00000000..2a3bb794 --- /dev/null +++ b/apps/dashboard/src/hooks/use-session.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { api } from '@client/lib/api'; +import type { AuthSessionUser } from '@codraoss/schema/api'; + +export interface SessionState { + user: AuthSessionUser | null; + permissions: string[] | undefined; + loading: boolean; +} + +const SessionContext = createContext({ user: null, permissions: undefined, loading: true }); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ user: null, permissions: undefined, loading: true }); + + useEffect(() => { + let cancelled = false; + api.getSession() + .then((r) => { + if (!cancelled) setState({ user: r.user, permissions: r.permissions, loading: false }); + }) + .catch(() => { + if (!cancelled) setState({ user: null, permissions: undefined, loading: false }); + }); + return () => { cancelled = true; }; + }, []); + + const value = useMemo(() => state, [state]); + return {children}; +} + +export function useSession() { + return useContext(SessionContext); +} diff --git a/src/client/hooks/use-stats-range.ts b/apps/dashboard/src/hooks/use-stats-range.ts similarity index 100% rename from src/client/hooks/use-stats-range.ts rename to apps/dashboard/src/hooks/use-stats-range.ts diff --git a/src/client/lib/api.ts b/apps/dashboard/src/lib/api.ts similarity index 100% rename from src/client/lib/api.ts rename to apps/dashboard/src/lib/api.ts diff --git a/src/client/lib/batch-groups.ts b/apps/dashboard/src/lib/batch-groups.ts similarity index 100% rename from src/client/lib/batch-groups.ts rename to apps/dashboard/src/lib/batch-groups.ts diff --git a/src/client/lib/diffs-cache.ts b/apps/dashboard/src/lib/diffs-cache.ts similarity index 100% rename from src/client/lib/diffs-cache.ts rename to apps/dashboard/src/lib/diffs-cache.ts diff --git a/src/client/lib/job-format.ts b/apps/dashboard/src/lib/job-format.ts similarity index 100% rename from src/client/lib/job-format.ts rename to apps/dashboard/src/lib/job-format.ts diff --git a/src/client/lib/timezone.ts b/apps/dashboard/src/lib/timezone.ts similarity index 100% rename from src/client/lib/timezone.ts rename to apps/dashboard/src/lib/timezone.ts diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx new file mode 100644 index 00000000..d180197e --- /dev/null +++ b/apps/dashboard/src/main.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; +import { Toaster } from 'sonner'; +import { buildRouter } from './routes'; + +import './app.css'; + +import { ThemeProvider } from '@codraoss/ui/theme'; +import { useIsDarkMode } from '@codraoss/ui/hooks'; +import { SmoothScroll } from '@codraoss/ui/motion'; + +function ToasterWrapper() { + const isDark = useIsDarkMode(); + return ( + + ); +} + +const router = buildRouter(); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + , +); diff --git a/apps/dashboard/src/nav.ts b/apps/dashboard/src/nav.ts new file mode 100644 index 00000000..18e7d4d9 --- /dev/null +++ b/apps/dashboard/src/nav.ts @@ -0,0 +1,20 @@ +import { LayoutDashboard, GitBranch, BarChart2, Activity, Settings } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { ApiAction } from '@codraoss/schema/api'; + +export interface NavItem { + to: string; + label: string; + icon: LucideIcon; + end?: boolean; + requiresAction?: ApiAction; +} + +// The sidebar's contents, kept here so navigation can be extended by composing this list; /account is intentionally absent because it lives in the account menu. +export const navItems: NavItem[] = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, end: true }, + { to: '/jobs', label: 'Jobs', icon: Activity, end: false }, + { to: '/repos', label: 'Repos', icon: GitBranch, end: false }, + { to: '/stats', label: 'Stats', icon: BarChart2, end: false }, + { to: '/settings', label: 'Settings', icon: Settings, end: false }, +]; diff --git a/src/client/pages/account.tsx b/apps/dashboard/src/pages/account.tsx similarity index 100% rename from src/client/pages/account.tsx rename to apps/dashboard/src/pages/account.tsx diff --git a/src/client/pages/dashboard.tsx b/apps/dashboard/src/pages/dashboard.tsx similarity index 79% rename from src/client/pages/dashboard.tsx rename to apps/dashboard/src/pages/dashboard.tsx index bc1dae78..418fd2ec 100644 --- a/src/client/pages/dashboard.tsx +++ b/apps/dashboard/src/pages/dashboard.tsx @@ -9,6 +9,7 @@ import { Link } from 'react-router-dom'; import { PageHeader } from '@client/components/layout/page-header'; import { OverviewStats } from '@client/components/features/stats/overview-stats'; +import { useFitRows } from '@client/hooks/use-fit-rows'; import { usePolling } from '@client/hooks/use-polling'; import { useStatsRange } from '@client/hooks/use-stats-range'; @@ -21,6 +22,10 @@ export function DashboardPage() { const [days, setDays] = useStatsRange(); + // Ask for exactly as many recent jobs as fit under the stats cards, so the panel fills the + // viewport without spilling into a page scroll. `null` until the first measurement lands. + const { ref: tableRef, rows } = useFitRows({ min: 4, max: 30 }); + // Clears stats to show skeletons while the new range loads; recent-jobs is range-independent and keeps its data. const changeDays = (next: number) => { setStats(null); @@ -28,11 +33,12 @@ export function DashboardPage() { }; const load = async (manual = false) => { + if (rows === null) return; if (manual) setRefreshing(true); try { const [statsRes, jobsRes] = await Promise.all([ api.getStats(days), - api.getJobs({ limit: 10 }), + api.getJobs({ limit: rows }), ]); setStats(statsRes.stats); setRecentJobs(jobsRes.jobs); @@ -45,7 +51,7 @@ export function DashboardPage() { } }; - usePolling(load, 15_000, [days]); + usePolling(load, 15_000, [days, rows]); return ( @@ -92,9 +98,13 @@ export function DashboardPage() {
-
- {(loading || recentJobs.length > 0) && ( - +
+ {/* Nothing renders until the measurement lands. JobsTable falls back to 8 skeleton rows + when `skeletonRows` is undefined, so rendering it early painted a too-long table that + then shrank to the fitted count. `useFitRows` measures in a layout effect, so `rows` + is set before the first paint - this costs no visible delay. */} + {rows !== null && (loading || recentJobs.length > 0) && ( + )} {!loading && recentJobs.length === 0 && ( diff --git a/src/client/pages/job-detail.tsx b/apps/dashboard/src/pages/job-detail.tsx similarity index 94% rename from src/client/pages/job-detail.tsx rename to apps/dashboard/src/pages/job-detail.tsx index 7552493b..a05ac237 100644 --- a/src/client/pages/job-detail.tsx +++ b/apps/dashboard/src/pages/job-detail.tsx @@ -6,6 +6,7 @@ import { ClipboardList, FileDiff } from 'lucide-react'; import { useJobDetail } from '@client/hooks/use-job-detail'; import { JobHeader } from '@client/components/features/job-detail/job-header'; import { JobProgress } from '@client/components/features/job-detail/job-progress'; +import { JobStatusNotice } from '@client/components/features/job-detail/job-status-notice'; import { JobMetaCards } from '@client/components/features/job-detail/job-meta-cards'; import { JobReviewOverview } from '@client/components/features/job-detail/job-review-overview'; import { JobFindingsList } from '@client/components/features/job-detail/job-findings-list'; @@ -60,6 +61,9 @@ export function JobDetailPage() { + {/* Terminal outcome (failed / superseded / stopped / partial) gets its own banner above the tabs. */} + + {/* domMax, not domAnimation: the underline uses `layoutId`, which needs the layout feature. */}