diff --git a/Backend/src/index.ts b/Backend/src/index.ts index 56f7379..a9e9767 100644 --- a/Backend/src/index.ts +++ b/Backend/src/index.ts @@ -86,7 +86,12 @@ server.notFound(async (ctr) => { // Serve a matching build file (assets, favicons, etc.) if present. const file = resolveStaticFile(path); - if (file) return ctr.status(200, "OK").printFile(file, { addTypes: true }); + if (file) { + // rjweb's type map has no entry for .webmanifest, and browsers want the + // PWA manifest served as application/manifest+json. + if (file.endsWith(".webmanifest")) ctr.headers.set("Content-Type", "application/manifest+json"); + return ctr.status(200, "OK").printFile(file, { addTypes: !file.endsWith(".webmanifest") }); + } // SPA fallback — hand any other route to the React app. if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true }); diff --git a/UI/index.html b/UI/index.html index 0593d03..cdbca07 100644 --- a/UI/index.html +++ b/UI/index.html @@ -2,9 +2,27 @@
+
How many requests this agent may have awaiting review at once (1–10).
Save this API key now — it won't be shown again.
+ Save this API key now — it won't be shown again. +
+ {lines.map((line, i) => { let cls = ""; @@ -13,7 +19,10 @@ function DiffView({ content }: { content: string }) { else if (line.startsWith("-")) cls = "diff-del"; else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta"; return ( - + {line || " "} ); @@ -38,14 +47,21 @@ function percentDelta(before: unknown, after: unknown): string | null { return `${pct > 0 ? "+" : ""}${pct}%`; } -function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) { - const delta = - contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null; +function BeforeAfter({ + before, + after, + contentType, +}: { + before: unknown; + after: unknown; + contentType?: string | null; +}) { + const delta = contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null; return ( - - {fmtValue(before)} + + {fmtValue(before)} → - {fmtValue(after)} + {fmtValue(after)} {delta && {delta}} {contentType && ({contentType})} @@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u } function typeChip(label: string, color: string) { - return {label}; + return {label}; } export function ChangeCard({ change, index }: { change: Change; index: number }) { + const [wrap, setWrap] = useState(false); + return ( - - - #{index + 1} + + + #{index + 1} + + + {change.type === "unified_diff" && ( + <> + {typeChip("diff", "bg-primary-dim/40 text-primary")} + {change.path} + > + )} + {change.type === "config" && ( + <> + {typeChip("config", "bg-accent/15 text-accent")} + {change.path} + > + )} + {change.type === "custom" && ( + <> + {typeChip("custom", "bg-changes/15 text-changes")} + {change.label} + > + )} + + + {/* Wrapping beats horizontal scrolling for long lines on a phone. */} {change.type === "unified_diff" && ( - <> - {typeChip("diff", "bg-primary-dim/40 text-primary")} - {change.path} - > - )} - {change.type === "config" && ( - <> - {typeChip("config", "bg-accent/15 text-accent")} - {change.path} - > - )} - {change.type === "custom" && ( - <> - {typeChip("custom", "bg-changes/15 text-changes")} - {change.label} - > + setWrap((w) => !w)} + aria-pressed={wrap} + title={wrap ? "Disable line wrapping" : "Wrap long lines"} + className={`tap-sm -mr-1 -mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition ${ + wrap ? "bg-primary/15 text-primary" : "text-faint hover:bg-surface-raised hover:text-muted" + }`} + > + + )} - {change.type === "unified_diff" && } + + {change.type === "unified_diff" && } {change.type === "config" && ( )} diff --git a/UI/src/components/CodeBlock.tsx b/UI/src/components/CodeBlock.tsx index f29855b..14a6b8a 100644 --- a/UI/src/components/CodeBlock.tsx +++ b/UI/src/components/CodeBlock.tsx @@ -1,30 +1,41 @@ import { useState } from "react"; +import { CheckIcon, CopyIcon } from "./icons"; export function CodeBlock({ code, language }: { code: string; language?: string }) { const [copied, setCopied] = useState(false); + const copy = async () => { try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { - /* ignore */ + /* clipboard unavailable (insecure origin) — nothing useful to do */ } }; + return ( {language && ( - + {language} )} - {copied ? "Copied!" : "Copy"} + {copied ? : } + {copied ? "Copied" : "Copy"} - + {code} diff --git a/UI/src/components/ConfirmSheet.tsx b/UI/src/components/ConfirmSheet.tsx new file mode 100644 index 0000000..7df4cfd --- /dev/null +++ b/UI/src/components/ConfirmSheet.tsx @@ -0,0 +1,52 @@ +import { Sheet } from "./Sheet"; +import { Button } from "./ui"; + +/** + * Replaces `window.confirm` for destructive actions — the native dialog is a + * hard stop that looks nothing like the app on a phone. + */ +export function ConfirmSheet({ + open, + onClose, + onConfirm, + title, + message, + confirmLabel = "Confirm", + danger, + loading, +}: { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmLabel?: string; + danger?: boolean; + loading?: boolean; +}) { + return ( + + + Cancel + + + {confirmLabel} + + > + } + > + {message} + + ); +} diff --git a/UI/src/components/DecisionModal.tsx b/UI/src/components/DecisionModal.tsx index 76574f4..85ae50b 100644 --- a/UI/src/components/DecisionModal.tsx +++ b/UI/src/components/DecisionModal.tsx @@ -9,7 +9,10 @@ type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES"; const MAX = 500; -const meta: Record = { +const meta: Record< + Decision, + { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean } +> = { APPROVE: { title: "Approve request", verb: "Approve", @@ -46,18 +49,24 @@ export function DecisionModal({ }) { const [comment, setComment] = useState(""); const [loading, setLoading] = useState(false); + // Held past `decision` going null so the sheet can animate out with its + // copy intact instead of blanking mid-dismiss. + const [shown, setShown] = useState(decision); useEffect(() => { - setComment(""); + if (decision) { + setShown(decision); + setComment(""); + } }, [decision]); - if (!decision) return null; - const m = meta[decision]; + if (!shown) return null; + const m = meta[shown]; const tooLong = comment.length > MAX; const missingRequired = m.commentRequired && comment.trim().length === 0; const submit = async () => { - if (tooLong || missingRequired) return; + if (!decision || tooLong || missingRequired) return; setLoading(true); try { const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined); @@ -79,10 +88,16 @@ export function DecisionModal({ title={m.title} footer={ <> - + Cancel - + {m.verb} > @@ -90,19 +105,22 @@ export function DecisionModal({ > - {request.title} + {request.title} - {request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "} - {request.agent?.name} + {request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name} {m.blurb} - + Comment {m.commentRequired ? (required) : "(optional)"} - MAX * 0.8 ? "text-pending" : "text-faint"}`}> + MAX * 0.8 ? "text-pending" : "text-faint" + }`} + > {comment.length}/{MAX} @@ -110,9 +128,11 @@ export function DecisionModal({ value={comment} onChange={(e) => setComment(e.target.value)} rows={4} - autoFocus + // Only steal focus (and raise the keyboard) when a comment is + // actually needed to submit. + autoFocus={m.commentRequired} placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"} - className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${ + className={`w-full rounded-lg border bg-bg px-3 py-2 text-base text-text outline-none focus:border-primary sm:text-sm ${ tooLong ? "border-rejected" : "border-border-strong" }`} /> diff --git a/UI/src/components/Layout.tsx b/UI/src/components/Layout.tsx index 4536a69..5ae252b 100644 --- a/UI/src/components/Layout.tsx +++ b/UI/src/components/Layout.tsx @@ -1,7 +1,18 @@ -import { ReactNode, useState } from "react"; +import { ReactNode, useEffect, useRef, useState } from "react"; import { Link, NavLink, useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { useNotifications } from "../context/NotificationsContext"; import { NotificationBell } from "./NotificationBell"; +import { Sheet } from "./Sheet"; +import { + AgentsIcon, + BellIcon, + HomeIcon, + LogOutIcon, + RequestsIcon, + SettingsIcon, + ShieldIcon, +} from "./icons"; const navItems = [ { to: "/", label: "Dashboard", end: true }, @@ -10,18 +21,27 @@ const navItems = [ { to: "/settings", label: "Settings" }, ]; +const tabs = [ + { to: "/", label: "Home", Icon: HomeIcon, end: true }, + { to: "/requests", label: "Requests", Icon: RequestsIcon }, + { to: "/agents", label: "Agents", Icon: AgentsIcon }, + { to: "/notifications", label: "Alerts", Icon: BellIcon, badge: true }, + { to: "/settings", label: "Settings", Icon: SettingsIcon }, +]; + export function Layout({ children }: { children: ReactNode }) { - const { user, logout } = useAuth(); - const navigate = useNavigate(); - const [menuOpen, setMenuOpen] = useState(false); + const { user } = useAuth(); return ( - - - + + + - - ✅ + + PatchPass @@ -57,93 +77,232 @@ export function Layout({ children }: { children: ReactNode }) { - - - setMenuOpen((o) => !o)} - className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised" - > - - {user?.display_name?.[0]?.toUpperCase()} - - {user?.display_name} - - {menuOpen && ( - setMenuOpen(false)} - > - setMenuOpen(false)} - > - Settings - - setMenuOpen(false)} - > - Notifications - - {user?.role === "ADMIN" && ( - setMenuOpen(false)} - > - Admin - - )} - { - await logout(); - navigate("/login"); - }} - className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised" - > - Log out - - - )} + {/* Below md the bell is replaced by the Alerts tab. */} + + + - {/* mobile nav */} - - {navItems.map((item) => ( - - `whitespace-nowrap rounded-lg px-2 py-1.5 text-sm font-medium ${ - isActive ? "bg-surface-raised text-text" : "text-muted" - }` - } - > - {item.label} - - ))} - - {children} + {children}
{lines.map((line, i) => { let cls = ""; @@ -13,7 +19,10 @@ function DiffView({ content }: { content: string }) { else if (line.startsWith("-")) cls = "diff-del"; else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta"; return ( - + {line || " "} ); @@ -38,14 +47,21 @@ function percentDelta(before: unknown, after: unknown): string | null { return `${pct > 0 ? "+" : ""}${pct}%`; } -function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) { - const delta = - contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null; +function BeforeAfter({ + before, + after, + contentType, +}: { + before: unknown; + after: unknown; + contentType?: string | null; +}) { + const delta = contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null; return ( - - {fmtValue(before)} + + {fmtValue(before)} → - {fmtValue(after)} + {fmtValue(after)} {delta && {delta}} {contentType && ({contentType})} @@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u } function typeChip(label: string, color: string) { - return {label}; + return {label}; } export function ChangeCard({ change, index }: { change: Change; index: number }) { + const [wrap, setWrap] = useState(false); + return ( - - - #{index + 1} + + + #{index + 1} + + + {change.type === "unified_diff" && ( + <> + {typeChip("diff", "bg-primary-dim/40 text-primary")} + {change.path} + > + )} + {change.type === "config" && ( + <> + {typeChip("config", "bg-accent/15 text-accent")} + {change.path} + > + )} + {change.type === "custom" && ( + <> + {typeChip("custom", "bg-changes/15 text-changes")} + {change.label} + > + )} + + + {/* Wrapping beats horizontal scrolling for long lines on a phone. */} {change.type === "unified_diff" && ( - <> - {typeChip("diff", "bg-primary-dim/40 text-primary")} - {change.path} - > - )} - {change.type === "config" && ( - <> - {typeChip("config", "bg-accent/15 text-accent")} - {change.path} - > - )} - {change.type === "custom" && ( - <> - {typeChip("custom", "bg-changes/15 text-changes")} - {change.label} - > + setWrap((w) => !w)} + aria-pressed={wrap} + title={wrap ? "Disable line wrapping" : "Wrap long lines"} + className={`tap-sm -mr-1 -mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition ${ + wrap ? "bg-primary/15 text-primary" : "text-faint hover:bg-surface-raised hover:text-muted" + }`} + > + + )} - {change.type === "unified_diff" && } + + {change.type === "unified_diff" && } {change.type === "config" && ( )} diff --git a/UI/src/components/CodeBlock.tsx b/UI/src/components/CodeBlock.tsx index f29855b..14a6b8a 100644 --- a/UI/src/components/CodeBlock.tsx +++ b/UI/src/components/CodeBlock.tsx @@ -1,30 +1,41 @@ import { useState } from "react"; +import { CheckIcon, CopyIcon } from "./icons"; export function CodeBlock({ code, language }: { code: string; language?: string }) { const [copied, setCopied] = useState(false); + const copy = async () => { try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { - /* ignore */ + /* clipboard unavailable (insecure origin) — nothing useful to do */ } }; + return ( {language && ( - + {language} )} - {copied ? "Copied!" : "Copy"} + {copied ? : } + {copied ? "Copied" : "Copy"} - + {code} diff --git a/UI/src/components/ConfirmSheet.tsx b/UI/src/components/ConfirmSheet.tsx new file mode 100644 index 0000000..7df4cfd --- /dev/null +++ b/UI/src/components/ConfirmSheet.tsx @@ -0,0 +1,52 @@ +import { Sheet } from "./Sheet"; +import { Button } from "./ui"; + +/** + * Replaces `window.confirm` for destructive actions — the native dialog is a + * hard stop that looks nothing like the app on a phone. + */ +export function ConfirmSheet({ + open, + onClose, + onConfirm, + title, + message, + confirmLabel = "Confirm", + danger, + loading, +}: { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmLabel?: string; + danger?: boolean; + loading?: boolean; +}) { + return ( + + + Cancel + + + {confirmLabel} + + > + } + > + {message} + + ); +} diff --git a/UI/src/components/DecisionModal.tsx b/UI/src/components/DecisionModal.tsx index 76574f4..85ae50b 100644 --- a/UI/src/components/DecisionModal.tsx +++ b/UI/src/components/DecisionModal.tsx @@ -9,7 +9,10 @@ type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES"; const MAX = 500; -const meta: Record = { +const meta: Record< + Decision, + { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean } +> = { APPROVE: { title: "Approve request", verb: "Approve", @@ -46,18 +49,24 @@ export function DecisionModal({ }) { const [comment, setComment] = useState(""); const [loading, setLoading] = useState(false); + // Held past `decision` going null so the sheet can animate out with its + // copy intact instead of blanking mid-dismiss. + const [shown, setShown] = useState(decision); useEffect(() => { - setComment(""); + if (decision) { + setShown(decision); + setComment(""); + } }, [decision]); - if (!decision) return null; - const m = meta[decision]; + if (!shown) return null; + const m = meta[shown]; const tooLong = comment.length > MAX; const missingRequired = m.commentRequired && comment.trim().length === 0; const submit = async () => { - if (tooLong || missingRequired) return; + if (!decision || tooLong || missingRequired) return; setLoading(true); try { const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined); @@ -79,10 +88,16 @@ export function DecisionModal({ title={m.title} footer={ <> - + Cancel - + {m.verb} > @@ -90,19 +105,22 @@ export function DecisionModal({ > - {request.title} + {request.title} - {request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "} - {request.agent?.name} + {request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name} {m.blurb} - + Comment {m.commentRequired ? (required) : "(optional)"} - MAX * 0.8 ? "text-pending" : "text-faint"}`}> + MAX * 0.8 ? "text-pending" : "text-faint" + }`} + > {comment.length}/{MAX} @@ -110,9 +128,11 @@ export function DecisionModal({ value={comment} onChange={(e) => setComment(e.target.value)} rows={4} - autoFocus + // Only steal focus (and raise the keyboard) when a comment is + // actually needed to submit. + autoFocus={m.commentRequired} placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"} - className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${ + className={`w-full rounded-lg border bg-bg px-3 py-2 text-base text-text outline-none focus:border-primary sm:text-sm ${ tooLong ? "border-rejected" : "border-border-strong" }`} /> diff --git a/UI/src/components/Layout.tsx b/UI/src/components/Layout.tsx index 4536a69..5ae252b 100644 --- a/UI/src/components/Layout.tsx +++ b/UI/src/components/Layout.tsx @@ -1,7 +1,18 @@ -import { ReactNode, useState } from "react"; +import { ReactNode, useEffect, useRef, useState } from "react"; import { Link, NavLink, useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { useNotifications } from "../context/NotificationsContext"; import { NotificationBell } from "./NotificationBell"; +import { Sheet } from "./Sheet"; +import { + AgentsIcon, + BellIcon, + HomeIcon, + LogOutIcon, + RequestsIcon, + SettingsIcon, + ShieldIcon, +} from "./icons"; const navItems = [ { to: "/", label: "Dashboard", end: true }, @@ -10,18 +21,27 @@ const navItems = [ { to: "/settings", label: "Settings" }, ]; +const tabs = [ + { to: "/", label: "Home", Icon: HomeIcon, end: true }, + { to: "/requests", label: "Requests", Icon: RequestsIcon }, + { to: "/agents", label: "Agents", Icon: AgentsIcon }, + { to: "/notifications", label: "Alerts", Icon: BellIcon, badge: true }, + { to: "/settings", label: "Settings", Icon: SettingsIcon }, +]; + export function Layout({ children }: { children: ReactNode }) { - const { user, logout } = useAuth(); - const navigate = useNavigate(); - const [menuOpen, setMenuOpen] = useState(false); + const { user } = useAuth(); return ( - - - + + + - - ✅ + + PatchPass @@ -57,93 +77,232 @@ export function Layout({ children }: { children: ReactNode }) { - - - setMenuOpen((o) => !o)} - className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised" - > - - {user?.display_name?.[0]?.toUpperCase()} - - {user?.display_name} - - {menuOpen && ( - setMenuOpen(false)} - > - setMenuOpen(false)} - > - Settings - - setMenuOpen(false)} - > - Notifications - - {user?.role === "ADMIN" && ( - setMenuOpen(false)} - > - Admin - - )} - { - await logout(); - navigate("/login"); - }} - className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised" - > - Log out - - - )} + {/* Below md the bell is replaced by the Alerts tab. */} + + + - {/* mobile nav */} - - {navItems.map((item) => ( - - `whitespace-nowrap rounded-lg px-2 py-1.5 text-sm font-medium ${ - isActive ? "bg-surface-raised text-text" : "text-muted" - }` - } - > - {item.label} - - ))} - - {children} + {children}
{fmtValue(before)}
{fmtValue(after)}
{change.path}
+ {code}
{code}
{message}
{request.title}
- {request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "} - {request.agent?.name} + {request.changes.length} change{request.changes.length === 1 ? "" : "s"} · {request.agent?.name}
{m.blurb}