diff --git a/apps/web/index.html b/apps/web/index.html index 35628f3..0713fbf 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,13 @@ Codewalk District +
diff --git a/apps/web/package.json b/apps/web/package.json index 7a5c529..bae73ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.75.0", "@tanstack/react-router": "^1.158.0", "@tiptap/extension-link": "^3.19.0", @@ -28,6 +29,7 @@ "@trpc/react-query": "^11.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "geist": "^1.7.0", "lucide-react": "^0.563.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/web/public/fonts/Geist-Variable.woff2 b/apps/web/public/fonts/Geist-Variable.woff2 new file mode 100644 index 0000000..b2f0121 Binary files /dev/null and b/apps/web/public/fonts/Geist-Variable.woff2 differ diff --git a/apps/web/public/fonts/GeistMono-Variable.woff2 b/apps/web/public/fonts/GeistMono-Variable.woff2 new file mode 100644 index 0000000..dbdb8c2 Binary files /dev/null and b/apps/web/public/fonts/GeistMono-Variable.woff2 differ diff --git a/apps/web/src/components/AccountCard.tsx b/apps/web/src/components/AccountCard.tsx index 3e68351..46b9a1b 100644 --- a/apps/web/src/components/AccountCard.tsx +++ b/apps/web/src/components/AccountCard.tsx @@ -35,10 +35,10 @@ function UsageBar({ }) { const color = utilization >= 90 - ? "bg-destructive" + ? "bg-status-error-dot" : utilization >= 70 - ? "bg-yellow-500" - : "bg-green-500"; + ? "bg-status-warning-dot" + : "bg-status-success-dot"; const resetText = resetsAt ? formatResetTime(resetsAt) : null; return (
@@ -96,13 +96,13 @@ export function AccountCard({ account }: { account: AccountData }) { const hasWarning = account.credentialsValid && !account.isExhausted && account.error; const statusIcon = !account.credentialsValid ? ( - + ) : account.isExhausted ? ( - + ) : hasWarning ? ( - + ) : ( - + ); const statusText = !account.credentialsValid @@ -197,7 +197,7 @@ export function AccountCard({ account }: { account: AccountData }) { {/* Error / warning message */} {account.error && ( -

+

{account.error}

)} diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 82c47a2..3eaaeb3 100644 --- a/apps/web/src/components/AgentOutputViewer.tsx +++ b/apps/web/src/components/AgentOutputViewer.tsx @@ -95,21 +95,21 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO const hasOutput = messages.length > 0; return ( -
+
{/* Header */} -
+
- + {agentName ? `Output: ${agentName}` : "Agent Output"} {subscription.error && ( -
+
Connection error
)} {subscription.isConnecting && ( - Connecting... + Connecting... )}
@@ -128,7 +128,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO variant="ghost" size="sm" onClick={() => setFollow(!follow)} - className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800" + className="h-7 text-terminal-muted hover:text-terminal-fg hover:bg-white/5" > {follow ? ( <> @@ -147,7 +147,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO variant="ghost" size="sm" onClick={scrollToBottom} - className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800" + className="h-7 text-terminal-muted hover:text-terminal-fg hover:bg-white/5" > Jump to bottom @@ -160,73 +160,73 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{isLoading ? ( -
Loading output...
+
Loading output...
) : !hasOutput ? ( -
No output yet...
+
No output yet...
) : (
{messages.map((message, index) => (
{message.type === 'system' && (
- System - {message.content} + System + {message.content}
)} {message.type === 'text' && ( -
+
{message.content}
)} {message.type === 'tool_call' && ( -
+
{message.meta?.toolName} -
+
{message.content}
)} {message.type === 'tool_result' && ( -
- +
+ Result -
+
{message.content}
)} {message.type === 'error' && ( -
+
Error -
+
{message.content}
)} {message.type === 'session_end' && ( -
+
{message.content} {message.meta?.cost && ( - ${message.meta.cost.toFixed(4)} + ${message.meta.cost.toFixed(4)} )} {message.meta?.duration && ( - {(message.meta.duration / 1000).toFixed(1)}s + {(message.meta.duration / 1000).toFixed(1)}s )}
diff --git a/apps/web/src/components/BrowserTitleUpdater.tsx b/apps/web/src/components/BrowserTitleUpdater.tsx new file mode 100644 index 0000000..b507cf7 --- /dev/null +++ b/apps/web/src/components/BrowserTitleUpdater.tsx @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { trpc } from "@/lib/trpc"; + +export function BrowserTitleUpdater() { + const agents = trpc.listAgents.useQuery(undefined, { + refetchInterval: 10000, + }); + + const runningCount = agents.data?.filter((a) => a.status === "running").length ?? 0; + const questionsCount = agents.data?.filter((a) => a.status === "waiting_for_input").length ?? 0; + + useEffect(() => { + const parts: string[] = []; + if (questionsCount > 0) parts.push(`${questionsCount} question${questionsCount > 1 ? "s" : ""}`); + if (runningCount > 0) parts.push(`${runningCount} running`); + + document.title = parts.length > 0 + ? `(${parts.join(", ")}) Codewalk District` + : "Codewalk District"; + }, [runningCount, questionsCount]); + + return null; +} diff --git a/apps/web/src/components/ChangeSetBanner.tsx b/apps/web/src/components/ChangeSetBanner.tsx index 825bfe2..a645af1 100644 --- a/apps/web/src/components/ChangeSetBanner.tsx +++ b/apps/web/src/components/ChangeSetBanner.tsx @@ -87,11 +87,11 @@ export function ChangeSetBanner({ changeSet, onDismiss }: ChangeSetBannerProps)
{conflicts && ( -
-

+

+

Conflicts detected:

-
    +
      {conflicts.map((c, i) => (
    • {c}
    • ))} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..2673e21 --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,231 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { Search, Briefcase, Bot, Settings, ArrowRight } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; + +interface CommandItem { + id: string; + label: string; + group: string; + path: string; + icon: typeof Briefcase; + meta?: string; +} + +function fuzzyMatch(query: string, text: string): boolean { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + const lower = text.toLowerCase(); + return terms.every((term) => lower.includes(term)); +} + +const RECENT_KEY = "cw-command-recent"; + +function getRecent(): string[] { + try { + return JSON.parse(sessionStorage.getItem(RECENT_KEY) ?? "[]"); + } catch { + return []; + } +} + +function addRecent(id: string) { + const recent = getRecent().filter((r) => r !== id); + recent.unshift(id); + sessionStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5))); +} + +interface CommandPaletteProps { + open: boolean; + onClose: () => void; +} + +export function CommandPalette({ open, onClose }: CommandPaletteProps) { + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const navigate = useNavigate(); + + const initiatives = trpc.listInitiatives.useQuery(undefined, { enabled: open }); + const agents = trpc.listAgents.useQuery(undefined, { enabled: open }); + + // Build items list + const items: CommandItem[] = []; + + // Static navigation items + const staticItems: CommandItem[] = [ + { id: "nav-initiatives", label: "Initiatives", group: "Navigation", path: "/initiatives", icon: Briefcase }, + { id: "nav-agents", label: "Agents", group: "Navigation", path: "/agents", icon: Bot }, + { id: "nav-inbox", label: "Inbox", group: "Navigation", path: "/inbox", icon: ArrowRight }, + { id: "nav-settings", label: "Settings", group: "Navigation", path: "/settings/health", icon: Settings }, + ]; + + for (const item of staticItems) items.push(item); + + if (initiatives.data) { + for (const init of initiatives.data) { + items.push({ + id: `initiative-${init.id}`, + label: init.name, + group: "Initiatives", + path: `/initiatives/${init.id}`, + icon: Briefcase, + meta: init.status, + }); + } + } + + if (agents.data) { + for (const agent of agents.data) { + items.push({ + id: `agent-${agent.id}`, + label: agent.name, + group: "Agents", + path: "/agents", + icon: Bot, + meta: agent.status, + }); + } + } + + // Filter + const filtered = query + ? items.filter((item) => fuzzyMatch(query, `${item.label} ${item.meta ?? ""}`)) + : items; + + // Group + const groups = new Map(); + for (const item of filtered) { + const list = groups.get(item.group) ?? []; + list.push(item); + groups.set(item.group, list); + } + + // Flat list for keyboard nav + const flatItems = Array.from(groups.values()).flat(); + + const select = useCallback( + (item: CommandItem) => { + addRecent(item.id); + onClose(); + navigate({ to: item.path }); + }, + [navigate, onClose], + ); + + // Focus input when opened + useEffect(() => { + if (open) { + setQuery(""); + setActiveIndex(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + // Reset active index on filter change + useEffect(() => { + setActiveIndex(0); + }, [query]); + + // Scroll active item into view + useEffect(() => { + const el = listRef.current?.querySelector('[data-active="true"]'); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + function handleKeyDown(e: React.KeyboardEvent) { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, flatItems.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (flatItems[activeIndex]) select(flatItems[activeIndex]); + break; + case "Escape": + e.preventDefault(); + onClose(); + break; + } + } + + if (!open) return null; + + let flatIndex = 0; + + return ( + <> + {/* Backdrop */} +
      + {/* Palette */} +
      + {/* Search */} +
      + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search initiatives, agents, settings..." + className="h-11 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + + ESC + +
      + {/* Results */} +
      + {flatItems.length === 0 ? ( +

      + No results +

      + ) : ( + Array.from(groups.entries()).map(([group, groupItems]) => ( +
      +

      + {group} +

      + {groupItems.map((item) => { + const isActive = flatIndex === activeIndex; + const idx = flatIndex; + flatIndex++; + const Icon = item.icon; + return ( + + ); + })} +
      + )) + )} +
      +
      + + ); +} diff --git a/apps/web/src/components/ConnectionBanner.tsx b/apps/web/src/components/ConnectionBanner.tsx new file mode 100644 index 0000000..1f43d79 --- /dev/null +++ b/apps/web/src/components/ConnectionBanner.tsx @@ -0,0 +1,32 @@ +import { WifiOff, Loader2 } from "lucide-react"; +import type { ConnectionState } from "@/hooks/useConnectionStatus"; + +interface ConnectionBannerProps { + state: ConnectionState; +} + +export function ConnectionBanner({ state }: ConnectionBannerProps) { + if (state === "connected") return null; + + return ( +
      + {state === "disconnected" ? ( + <> + + Offline — connection lost + + ) : ( + <> + + Reconnecting... + + )} +
      + ); +} diff --git a/apps/web/src/components/DependencyIndicator.tsx b/apps/web/src/components/DependencyIndicator.tsx index 254cd0a..f4ecdfb 100644 --- a/apps/web/src/components/DependencyIndicator.tsx +++ b/apps/web/src/components/DependencyIndicator.tsx @@ -21,7 +21,7 @@ export function DependencyIndicator({ const names = blockedBy.map((item) => item.name).join(", "); return ( -
      +
      ^ blocked by: {names}
      ); diff --git a/apps/web/src/components/EmptyState.tsx b/apps/web/src/components/EmptyState.tsx new file mode 100644 index 0000000..13f90e3 --- /dev/null +++ b/apps/web/src/components/EmptyState.tsx @@ -0,0 +1,25 @@ +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface EmptyStateProps { + icon?: LucideIcon; + title: string; + description?: string; + action?: React.ReactNode; + className?: string; +} + +export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) { + return ( +
      + {Icon && } +
      +

      {title}

      + {description && ( +

      {description}

      + )} +
      + {action} +
      + ); +} diff --git a/apps/web/src/components/ErrorState.tsx b/apps/web/src/components/ErrorState.tsx new file mode 100644 index 0000000..9f46b6f --- /dev/null +++ b/apps/web/src/components/ErrorState.tsx @@ -0,0 +1,25 @@ +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface ErrorStateProps { + message?: string; + onRetry?: () => void; + className?: string; +} + +export function ErrorState({ message, onRetry, className }: ErrorStateProps) { + return ( +
      + +

      + {message ?? "Something went wrong"} +

      + {onRetry && ( + + )} +
      + ); +} diff --git a/apps/web/src/components/HealthDot.tsx b/apps/web/src/components/HealthDot.tsx new file mode 100644 index 0000000..579bf0f --- /dev/null +++ b/apps/web/src/components/HealthDot.tsx @@ -0,0 +1,35 @@ +import { trpc } from "@/lib/trpc"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export function HealthDot() { + const health = trpc.healthCheck.useQuery(undefined, { + refetchInterval: 30000, + retry: 1, + }); + + const isHealthy = health.data && !health.isError; + const isLoading = health.isLoading; + + return ( + + + + + + {isLoading + ? "Checking server..." + : isHealthy + ? "Server connected" + : "Server disconnected"} + + + ); +} diff --git a/apps/web/src/components/InitiativeHeader.tsx b/apps/web/src/components/InitiativeHeader.tsx index a6fcc26..265e7e0 100644 --- a/apps/web/src/components/InitiativeHeader.tsx +++ b/apps/web/src/components/InitiativeHeader.tsx @@ -104,8 +104,8 @@ export function InitiativeHeader({ variant="outline" className={`cursor-pointer select-none transition-colors ${ initiative.executionMode === "yolo" - ? "border-orange-300 text-orange-700 text-[10px] hover:bg-orange-50" - : "border-blue-300 text-blue-700 text-[10px] hover:bg-blue-50" + ? "border-status-warning-border text-status-warning-fg text-[10px] hover:bg-status-warning-bg" + : "border-status-active-border text-status-active-fg text-[10px] hover:bg-status-active-bg" }`} onClick={toggleExecutionMode} > diff --git a/apps/web/src/components/KeyboardShortcutHint.tsx b/apps/web/src/components/KeyboardShortcutHint.tsx new file mode 100644 index 0000000..db719a9 --- /dev/null +++ b/apps/web/src/components/KeyboardShortcutHint.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; + +interface KeyboardShortcutHintProps { + keys: string[]; + className?: string; +} + +function formatKey(key: string): string { + const isMac = navigator.platform.toUpperCase().includes("MAC"); + switch (key.toLowerCase()) { + case "mod": + case "cmd": + return isMac ? "\u2318" : "Ctrl"; + case "shift": + return isMac ? "\u21E7" : "Shift"; + case "alt": + case "option": + return isMac ? "\u2325" : "Alt"; + case "enter": + case "return": + return "\u21B5"; + case "escape": + case "esc": + return "Esc"; + case "backspace": + return "\u232B"; + case "delete": + return "Del"; + case "arrowup": + return "\u2191"; + case "arrowdown": + return "\u2193"; + case "arrowleft": + return "\u2190"; + case "arrowright": + return "\u2192"; + default: + return key.toUpperCase(); + } +} + +export function KeyboardShortcutHint({ keys, className }: KeyboardShortcutHintProps) { + return ( + + {keys.map((key, i) => ( + + {formatKey(key)} + + ))} + + ); +} diff --git a/apps/web/src/components/MessageCard.tsx b/apps/web/src/components/MessageCard.tsx index 0bccfa8..2f0bad9 100644 --- a/apps/web/src/components/MessageCard.tsx +++ b/apps/web/src/components/MessageCard.tsx @@ -43,7 +43,7 @@ export function MessageCard({ {requiresResponse ? "\u25CF" : "\u25CB"} diff --git a/apps/web/src/components/NavBadge.tsx b/apps/web/src/components/NavBadge.tsx new file mode 100644 index 0000000..226a2ca --- /dev/null +++ b/apps/web/src/components/NavBadge.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/lib/utils"; + +interface NavBadgeProps { + count: number; + className?: string; +} + +export function NavBadge({ count, className }: NavBadgeProps) { + if (count <= 0) return null; + + return ( + + {count > 99 ? "99+" : count} + + ); +} diff --git a/apps/web/src/components/ProgressBar.tsx b/apps/web/src/components/ProgressBar.tsx index 9d4e40d..29ac94c 100644 --- a/apps/web/src/components/ProgressBar.tsx +++ b/apps/web/src/components/ProgressBar.tsx @@ -13,7 +13,7 @@ export function ProgressBar({ completed, total, className }: ProgressBarProps) { percentage === 0 ? "" : percentage === 100 - ? "bg-green-500" + ? "bg-status-success-dot" : "bg-primary"; return ( diff --git a/apps/web/src/components/SaveIndicator.tsx b/apps/web/src/components/SaveIndicator.tsx new file mode 100644 index 0000000..6dd62d4 --- /dev/null +++ b/apps/web/src/components/SaveIndicator.tsx @@ -0,0 +1,44 @@ +import { Check, Loader2, AlertCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type SaveStatus = "idle" | "saving" | "saved" | "error"; + +interface SaveIndicatorProps { + status: SaveStatus; + className?: string; +} + +export function SaveIndicator({ status, className }: SaveIndicatorProps) { + if (status === "idle") return null; + + return ( + + {status === "saving" && ( + <> + + Saving... + + )} + {status === "saved" && ( + <> + + Saved + + )} + {status === "error" && ( + <> + + Save failed + + )} + + ); +} diff --git a/apps/web/src/components/Skeleton.tsx b/apps/web/src/components/Skeleton.tsx index 0f690cb..542f7a8 100644 --- a/apps/web/src/components/Skeleton.tsx +++ b/apps/web/src/components/Skeleton.tsx @@ -2,10 +2,19 @@ import { cn } from "@/lib/utils"; interface SkeletonProps { className?: string; + variant?: "line" | "circle" | "rect"; } -export function Skeleton({ className }: SkeletonProps) { +export function Skeleton({ className, variant = "rect" }: SkeletonProps) { return ( -
      +
      ); } diff --git a/apps/web/src/components/SkeletonCard.tsx b/apps/web/src/components/SkeletonCard.tsx new file mode 100644 index 0000000..e6f8a90 --- /dev/null +++ b/apps/web/src/components/SkeletonCard.tsx @@ -0,0 +1,71 @@ +import { Skeleton } from "./Skeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +type Layout = "agent-card" | "initiative-card" | "conversation-card" | "project-card" | "account-card"; + +interface SkeletonCardProps { + layout?: Layout; + className?: string; +} + +export function SkeletonCard({ layout = "initiative-card", className }: SkeletonCardProps) { + return ( + + + {layout === "agent-card" && ( +
      +
      + + + +
      + +
      + )} + {layout === "initiative-card" && ( +
      +
      + + +
      + + +
      + )} + {layout === "conversation-card" && ( +
      +
      + + + +
      + + +
      + )} + {layout === "project-card" && ( +
      + + +
      + )} + {layout === "account-card" && ( +
      +
      + +
      + + +
      +
      +
      + + +
      +
      + )} +
      +
      + ); +} diff --git a/apps/web/src/components/StatusBadge.tsx b/apps/web/src/components/StatusBadge.tsx index 7e2fb8c..a2e1622 100644 --- a/apps/web/src/components/StatusBadge.tsx +++ b/apps/web/src/components/StatusBadge.tsx @@ -1,23 +1,16 @@ import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; +import { mapEntityStatus, type StatusVariant } from "./StatusDot"; -const statusStyles: Record = { - // Initiative statuses - active: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200", - completed: "bg-green-100 text-green-800 hover:bg-green-100/80 border-green-200", - archived: "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200", - - // Phase statuses - pending: "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200", - approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200", - in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200", - blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200", - pending_review: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200", - pending_approval: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200", +const statusStyles: Record = { + active: "bg-status-active-bg text-status-active-fg border-status-active-border", + success: "bg-status-success-bg text-status-success-fg border-status-success-border", + warning: "bg-status-warning-bg text-status-warning-fg border-status-warning-border", + error: "bg-status-error-bg text-status-error-fg border-status-error-border", + neutral: "bg-status-neutral-bg text-status-neutral-fg border-status-neutral-border", + urgent: "bg-status-urgent-bg text-status-urgent-fg border-status-urgent-border", }; -const defaultStyle = "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200"; - function formatStatusText(status: string): string { return status.replace(/_/g, " ").toUpperCase(); } @@ -29,10 +22,11 @@ interface StatusBadgeProps { export function StatusBadge({ status, className }: StatusBadgeProps) { if (!status) return null; - const style = statusStyles[status] ?? defaultStyle; + const variant = mapEntityStatus(status); + const style = statusStyles[variant]; return ( - + {formatStatusText(status)} ); diff --git a/apps/web/src/components/StatusDot.tsx b/apps/web/src/components/StatusDot.tsx index beb7a63..a7f26d3 100644 --- a/apps/web/src/components/StatusDot.tsx +++ b/apps/web/src/components/StatusDot.tsx @@ -1,76 +1,99 @@ import { cn } from "@/lib/utils"; -/** - * Color mapping for different status values. - * Uses semantic colors that work well as small dots. - */ -const statusColors: Record = { - // Task statuses - pending: "bg-gray-400", - pending_approval: "bg-yellow-400", - in_progress: "bg-blue-500", - completed: "bg-green-500", - blocked: "bg-red-500", +export type StatusVariant = "active" | "success" | "warning" | "error" | "neutral" | "urgent"; - // Agent statuses - idle: "bg-gray-400", - running: "bg-blue-500", - waiting_for_input: "bg-yellow-400", - stopped: "bg-gray-600", - crashed: "bg-red-500", +const dotColors: Record = { + active: "bg-status-active-dot", + success: "bg-status-success-dot", + warning: "bg-status-warning-dot", + error: "bg-status-error-dot", + neutral: "bg-status-neutral-dot", + urgent: "bg-status-urgent-dot", +}; - // Initiative/Phase statuses - active: "bg-blue-500", - archived: "bg-gray-400", +/** Maps raw entity status strings to semantic StatusVariant tokens. */ +export function mapEntityStatus(rawStatus: string): StatusVariant { + switch (rawStatus) { + // Active / in-progress + case "running": + case "in_progress": + case "active": + case "building": + case "responded": + return "active"; - // Message statuses - read: "bg-green-500", - responded: "bg-blue-500", + // Success / completed + case "completed": + case "read": + case "low": + return "success"; - // Priority indicators - low: "bg-green-400", - medium: "bg-yellow-400", - high: "bg-red-400", -} as const; + // Warning / needs attention + case "waiting_for_input": + case "pending_approval": + case "pending_review": + case "approved": + case "exhausted": + case "medium": + return "warning"; -const defaultColor = "bg-gray-400"; + // Error / failed + case "crashed": + case "blocked": + case "failed": + case "high": + return "error"; + + // Urgent + // (reserved for future use, no current raw statuses map here) + + // Neutral / idle + case "pending": + case "idle": + case "stopped": + case "exited": + case "archived": + default: + return "neutral"; + } +} interface StatusDotProps { status: string; size?: "sm" | "md" | "lg"; + pulse?: boolean; + label?: string; className?: string; - title?: string; } -/** - * Small colored dot to indicate status at a glance. - * More compact than StatusBadge for use in lists or tight spaces. - */ export function StatusDot({ status, size = "md", + pulse = false, + label, className, - title }: StatusDotProps) { const sizeClasses = { sm: "h-2 w-2", md: "h-3 w-3", - lg: "h-4 w-4" + lg: "h-4 w-4", }; - const color = statusColors[status] ?? defaultColor; - const displayTitle = title ?? status.replace(/_/g, " ").toLowerCase(); + const variant = mapEntityStatus(status); + const color = dotColors[variant]; + const displayLabel = label ?? status.replace(/_/g, " ").toLowerCase(); return ( -
      ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..e616d0d --- /dev/null +++ b/apps/web/src/components/ThemeToggle.tsx @@ -0,0 +1,38 @@ +import { Sun, Monitor, Moon } from 'lucide-react'; +import { useTheme, type Theme } from '../lib/theme'; + +const options: { value: Theme; icon: typeof Sun; label: string }[] = [ + { value: 'light', icon: Sun, label: 'Light' }, + { value: 'system', icon: Monitor, label: 'System' }, + { value: 'dark', icon: Moon, label: 'Dark' }, +]; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( +
      + {options.map(({ value, icon: Icon, label }) => ( + + ))} +
      + ); +} diff --git a/apps/web/src/components/execution/PhaseDetailPanel.tsx b/apps/web/src/components/execution/PhaseDetailPanel.tsx index 26352d1..81bb657 100644 --- a/apps/web/src/components/execution/PhaseDetailPanel.tsx +++ b/apps/web/src/components/execution/PhaseDetailPanel.tsx @@ -268,8 +268,8 @@ export function PhaseDetailPanel({ {/* Pending review banner */} {isPendingReview && ( -
      -

      +

      +

      This phase is pending review. Switch to the{" "} Review tab to view the diff and approve.

      @@ -322,7 +322,7 @@ export function PhaseDetailPanel({ diff --git a/apps/web/src/components/pipeline/PipelineTaskCard.tsx b/apps/web/src/components/pipeline/PipelineTaskCard.tsx index f4ba25e..1035fb2 100644 --- a/apps/web/src/components/pipeline/PipelineTaskCard.tsx +++ b/apps/web/src/components/pipeline/PipelineTaskCard.tsx @@ -5,11 +5,11 @@ import { useExecutionContext } from "@/components/execution"; import type { SerializedTask } from "@/components/TaskRow"; const statusConfig: Record = { - pending: { icon: Clock, color: "text-muted-foreground" }, - pending_approval: { icon: AlertTriangle, color: "text-yellow-500" }, - in_progress: { icon: Loader2, color: "text-blue-500", spin: true }, - completed: { icon: CheckCircle2, color: "text-green-500" }, - blocked: { icon: Ban, color: "text-red-500" }, + pending: { icon: Clock, color: "text-status-neutral-fg" }, + pending_approval: { icon: AlertTriangle, color: "text-status-warning-dot" }, + in_progress: { icon: Loader2, color: "text-status-active-dot", spin: true }, + completed: { icon: CheckCircle2, color: "text-status-success-dot" }, + blocked: { icon: Ban, color: "text-status-error-dot" }, }; interface PipelineTaskCardProps { diff --git a/apps/web/src/components/review/CommentThread.tsx b/apps/web/src/components/review/CommentThread.tsx index 09fd94f..6599e34 100644 --- a/apps/web/src/components/review/CommentThread.tsx +++ b/apps/web/src/components/review/CommentThread.tsx @@ -16,7 +16,7 @@ export function CommentThread({ comments, onResolve, onUnresolve }: CommentThrea key={comment.id} className={`rounded border p-2.5 text-xs space-y-1.5 ${ comment.resolved - ? "border-green-200 dark:border-green-900 bg-green-50/50 dark:bg-green-950/10" + ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card" }`} > @@ -27,7 +27,7 @@ export function CommentThread({ comments, onResolve, onUnresolve }: CommentThrea {formatTime(comment.createdAt)} {comment.resolved && ( - + Resolved diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index d4e02f4..3035424 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -42,13 +42,13 @@ export function FileCard({ {file.newPath} {file.additions > 0 && ( - + {file.additions} )} {file.deletions > 0 && ( - + {file.deletions} diff --git a/apps/web/src/components/review/HunkRows.tsx b/apps/web/src/components/review/HunkRows.tsx index e8f6ddc..baaedca 100644 --- a/apps/web/src/components/review/HunkRows.tsx +++ b/apps/web/src/components/review/HunkRows.tsx @@ -49,7 +49,7 @@ export function HunkRows({ {hunk.header} diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index 56eacd5..4afd603 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -37,16 +37,16 @@ export function LineWithComments({ const bgClass = line.type === "added" - ? "bg-green-50 dark:bg-green-950/20" + ? "bg-diff-add-bg" : line.type === "removed" - ? "bg-red-50 dark:bg-red-950/20" + ? "bg-diff-remove-bg" : ""; const gutterBgClass = line.type === "added" - ? "bg-green-100 dark:bg-green-950/40" + ? "bg-diff-add-bg" : line.type === "removed" - ? "bg-red-100 dark:bg-red-950/40" + ? "bg-diff-remove-bg" : "bg-muted/30"; const prefix = @@ -54,9 +54,9 @@ export function LineWithComments({ const textColorClass = line.type === "added" - ? "text-green-800 dark:text-green-300" + ? "text-diff-add-fg" : line.type === "removed" - ? "text-red-800 dark:text-red-300" + ? "text-diff-remove-fg" : ""; return ( @@ -81,7 +81,7 @@ export function LineWithComments({ {/* Comment button gutter */} ); @@ -190,23 +190,23 @@ export function ReviewSidebar({ ); } -function StatusBadge({ status }: { status: ReviewStatus }) { +function ReviewStatusBadge({ status }: { status: ReviewStatus }) { if (status === "approved") { return ( - + Approved ); } if (status === "changes_requested") { return ( - + Changes Requested ); } return ( - + Pending Review ); diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index f000e3e..b521fe0 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-full border font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { @@ -15,10 +15,27 @@ const badgeVariants = cva( destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", outline: "text-foreground", + active: + "border-status-active-border bg-status-active-bg text-status-active-fg", + success: + "border-status-success-border bg-status-success-bg text-status-success-fg", + warning: + "border-status-warning-border bg-status-warning-bg text-status-warning-fg", + error: + "border-status-error-border bg-status-error-bg text-status-error-fg", + neutral: + "border-status-neutral-border bg-status-neutral-bg text-status-neutral-fg", + urgent: + "border-status-urgent-border bg-status-urgent-bg text-status-urgent-fg", + }, + size: { + sm: "px-2.5 py-0.5 text-xs", + xs: "px-1.5 py-0 text-[10px]", }, }, defaultVariants: { variant: "default", + size: "sm", }, } ) @@ -27,9 +44,9 @@ export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant, ...props }: BadgeProps) { +function Badge({ className, variant, size, ...props }: BadgeProps) { return ( -
      +
      ) } diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 61522f6..f724d3a 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -4,9 +4,13 @@ export function Toaster() { return ( ); diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..a12e303 --- /dev/null +++ b/apps/web/src/components/ui/tooltip.tsx @@ -0,0 +1,25 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { cn } from '../../lib/utils'; + +const TooltipProvider = TooltipPrimitive.Provider; +const Tooltip = TooltipPrimitive.Root; +const TooltipTrigger = TooltipPrimitive.Trigger; + +function TooltipContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/web/src/hooks/useConnectionStatus.ts b/apps/web/src/hooks/useConnectionStatus.ts new file mode 100644 index 0000000..6035d7b --- /dev/null +++ b/apps/web/src/hooks/useConnectionStatus.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from "react"; + +export type ConnectionState = "connected" | "reconnecting" | "disconnected"; + +export function useConnectionStatus(): ConnectionState { + const [state, setState] = useState("connected"); + + useEffect(() => { + function handleOnline() { + setState("connected"); + } + function handleOffline() { + setState("disconnected"); + } + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // Initial state + if (!navigator.onLine) { + setState("disconnected"); + } + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + return state; +} diff --git a/apps/web/src/hooks/useGlobalKeyboard.ts b/apps/web/src/hooks/useGlobalKeyboard.ts new file mode 100644 index 0000000..aeb91ec --- /dev/null +++ b/apps/web/src/hooks/useGlobalKeyboard.ts @@ -0,0 +1,54 @@ +import { useEffect } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +const NAV_KEYS: Record = { + "1": "/initiatives", + "2": "/agents", + "3": "/inbox", + "4": "/settings/health", +}; + +interface UseGlobalKeyboardOptions { + onCommandK: () => void; +} + +export function useGlobalKeyboard({ onCommandK }: UseGlobalKeyboardOptions) { + const navigate = useNavigate(); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + // Skip if user is typing in an input/textarea/contenteditable + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + // But still allow Cmd+K even in inputs + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + onCommandK(); + } + return; + } + + // Cmd+K / Ctrl+K — command palette + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + onCommandK(); + return; + } + + // Number keys 1-4 for quick nav (no modifier) + if (!e.metaKey && !e.ctrlKey && !e.altKey) { + const path = NAV_KEYS[e.key]; + if (path) { + navigate({ to: path }); + } + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [navigate, onCommandK]); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c23927d..4301c2f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,51 +1,307 @@ +@font-face { + font-family: 'Geist Sans'; + src: url('/fonts/Geist-Variable.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist Mono'; + src: url('/fonts/GeistMono-Variable.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; + /* Core palette — Indigo brand */ + --background: 0 0% 99%; + --foreground: 240 6% 10%; + --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; + --card-foreground: 240 6% 10%; + --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --radius: 0.5rem; + --popover-foreground: 240 6% 10%; + + --primary: 239 84% 67%; + --primary-foreground: 0 0% 100%; + + --secondary: 240 5% 96%; + --secondary-foreground: 240 4% 16%; + + --muted: 240 5% 93%; + --muted-foreground: 240 4% 46%; + + --accent: 226 100% 97%; + --accent-foreground: 239 84% 67%; + + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 100%; + + --border: 240 6% 90%; + --input: 240 6% 90%; + --ring: 239 84% 67%; + + --radius: 0.375rem; + + /* Extended indigo scale */ + --indigo-50: 226 100% 97%; + --indigo-100: 228 96% 93%; + --indigo-200: 232 92% 86%; + --indigo-300: 235 88% 78%; + --indigo-400: 237 86% 72%; + --indigo-500: 239 84% 67%; + --indigo-600: 243 75% 59%; + --indigo-700: 245 58% 51%; + --indigo-800: 244 47% 42%; + --indigo-900: 242 47% 34%; + + /* Status tokens — active */ + --status-active-bg: 210 100% 95%; + --status-active-fg: 210 100% 40%; + --status-active-border: 210 100% 80%; + --status-active-dot: 210 100% 50%; + + /* Status tokens — success */ + --status-success-bg: 142 72% 94%; + --status-success-fg: 142 72% 29%; + --status-success-border: 142 72% 80%; + --status-success-dot: 142 72% 45%; + + /* Status tokens — warning */ + --status-warning-bg: 38 92% 95%; + --status-warning-fg: 38 92% 30%; + --status-warning-border: 38 92% 80%; + --status-warning-dot: 38 92% 50%; + + /* Status tokens — error */ + --status-error-bg: 0 84% 95%; + --status-error-fg: 0 84% 40%; + --status-error-border: 0 84% 80%; + --status-error-dot: 0 84% 50%; + + /* Status tokens — neutral */ + --status-neutral-bg: 240 5% 96%; + --status-neutral-fg: 240 4% 46%; + --status-neutral-border: 240 6% 90%; + --status-neutral-dot: 240 4% 46%; + + /* Status tokens — urgent */ + --status-urgent-bg: 270 91% 95%; + --status-urgent-fg: 270 91% 40%; + --status-urgent-border: 270 91% 80%; + --status-urgent-dot: 270 91% 55%; + + /* Terminal tokens (always-dark aesthetic) */ + --terminal-bg: 240 6% 7%; + --terminal-fg: 120 100% 80%; + --terminal-muted: 240 5% 55%; + --terminal-border: 240 4% 16%; + --terminal-selection: 239 84% 67%; + --terminal-system: 240 5% 55%; + --terminal-tool: 217 91% 60%; + --terminal-result: 142 72% 45%; + --terminal-error: 0 84% 60%; + --terminal-cursor: 120 100% 65%; + --terminal-selection-bg: 239 84% 67% / 0.25; + --terminal-link: 217 91% 70%; + --terminal-warning: 38 92% 60%; + --terminal-line-number: 240 5% 35%; + --terminal-ansi-black: 240 6% 7%; + --terminal-ansi-red: 0 84% 60%; + --terminal-ansi-green: 142 72% 45%; + --terminal-ansi-yellow: 38 92% 60%; + --terminal-ansi-blue: 217 91% 60%; + --terminal-ansi-magenta: 270 91% 65%; + --terminal-ansi-cyan: 195 80% 55%; + --terminal-ansi-white: 240 5% 85%; + + /* Diff tokens */ + --diff-add-bg: 142 72% 94%; + --diff-add-fg: 142 72% 29%; + --diff-add-border: 142 72% 80%; + --diff-remove-bg: 0 84% 95%; + --diff-remove-fg: 0 84% 40%; + --diff-remove-border: 0 84% 80%; + --diff-hunk-bg: 226 100% 97%; + + /* Shadow tokens */ + --shadow-xs: 0 1px 2px hsl(0 0% 0% / 0.04); + --shadow-sm: 0 1px 3px hsl(0 0% 0% / 0.06), 0 1px 2px hsl(0 0% 0% / 0.04); + --shadow-md: 0 4px 6px hsl(0 0% 0% / 0.06), 0 2px 4px hsl(0 0% 0% / 0.04); + --shadow-lg: 0 10px 15px hsl(0 0% 0% / 0.06), 0 4px 6px hsl(0 0% 0% / 0.04); + --shadow-xl: 0 20px 25px hsl(0 0% 0% / 0.08), 0 8px 10px hsl(0 0% 0% / 0.04); + + /* Transition & animation tokens */ + --duration-instant: 50ms; + --duration-fast: 100ms; + --duration-normal: 200ms; + --duration-slow: 350ms; + --duration-glacial: 500ms; + --ease-default: cubic-bezier(0.2, 0, 0, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Z-index scale */ + --z-base: 0; + --z-raised: 1; + --z-sticky: 10; + --z-sidebar: 20; + --z-dropdown: 30; + --z-overlay: 40; + --z-modal: 50; + --z-toast: 60; + --z-command: 70; + --z-tooltip: 80; } .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; + --background: 240 6% 7%; + --foreground: 240 5% 96%; + + --card: 240 5% 11%; + --card-foreground: 240 5% 96%; + + --popover: 240 5% 16%; + --popover-foreground: 240 5% 96%; + + --primary: 239 84% 67%; + --primary-foreground: 0 0% 100%; + + --secondary: 240 4% 16%; + --secondary-foreground: 240 5% 96%; + + --muted: 240 4% 16%; + --muted-foreground: 240 5% 65%; + + --accent: 239 40% 16%; + --accent-foreground: 239 84% 75%; + + --destructive: 0 63% 31%; + --destructive-foreground: 0 0% 100%; + + --border: 240 4% 16%; + --input: 240 4% 16%; + --ring: 239 84% 67%; + + /* Surface level 3 for command palette */ + --surface-3: 240 5% 21%; + + /* Status tokens — dark mode */ + --status-active-bg: 210 100% 12%; + --status-active-fg: 210 100% 70%; + --status-active-border: 210 100% 25%; + --status-active-dot: 210 100% 50%; + + --status-success-bg: 142 72% 10%; + --status-success-fg: 142 72% 65%; + --status-success-border: 142 72% 22%; + --status-success-dot: 142 72% 45%; + + --status-warning-bg: 38 92% 10%; + --status-warning-fg: 38 92% 65%; + --status-warning-border: 38 92% 22%; + --status-warning-dot: 38 92% 50%; + + --status-error-bg: 0 84% 12%; + --status-error-fg: 0 84% 65%; + --status-error-border: 0 84% 25%; + --status-error-dot: 0 84% 50%; + + --status-neutral-bg: 240 4% 16%; + --status-neutral-fg: 240 5% 65%; + --status-neutral-border: 240 4% 22%; + --status-neutral-dot: 240 5% 50%; + + --status-urgent-bg: 270 91% 12%; + --status-urgent-fg: 270 91% 70%; + --status-urgent-border: 270 91% 25%; + --status-urgent-dot: 270 91% 55%; + + /* Terminal tokens — dark mode */ + --terminal-bg: 240 5% 11%; + --terminal-fg: 120 100% 80%; + --terminal-muted: 240 5% 55%; + --terminal-border: 240 4% 16%; + --terminal-selection: 239 84% 67%; + --terminal-system: 240 5% 55%; + --terminal-tool: 217 91% 60%; + --terminal-result: 142 72% 45%; + --terminal-error: 0 84% 60%; + --terminal-cursor: 120 100% 65%; + --terminal-selection-bg: 239 84% 67% / 0.25; + --terminal-link: 217 91% 70%; + --terminal-warning: 38 92% 60%; + --terminal-line-number: 240 5% 35%; + --terminal-ansi-black: 240 6% 7%; + --terminal-ansi-red: 0 84% 60%; + --terminal-ansi-green: 142 72% 45%; + --terminal-ansi-yellow: 38 92% 60%; + --terminal-ansi-blue: 217 91% 60%; + --terminal-ansi-magenta: 270 91% 65%; + --terminal-ansi-cyan: 195 80% 55%; + --terminal-ansi-white: 240 5% 85%; + + /* Diff tokens — dark mode */ + --diff-add-bg: 142 72% 10%; + --diff-add-fg: 142 72% 65%; + --diff-add-border: 142 72% 22%; + --diff-remove-bg: 0 84% 12%; + --diff-remove-fg: 0 84% 65%; + --diff-remove-border: 0 84% 25%; + --diff-hunk-bg: 239 40% 16%; + + /* Shadow tokens — dark mode (inset highlights + ambient glow) */ + --shadow-xs: none; + --shadow-sm: inset 0 1px 0 hsl(0 0% 100% / 0.04); + --shadow-md: inset 0 1px 0 hsl(0 0% 100% / 0.05), 0 2px 8px hsl(0 0% 0% / 0.3); + --shadow-lg: inset 0 1px 0 hsl(0 0% 100% / 0.06), 0 4px 16px hsl(0 0% 0% / 0.4); + --shadow-xl: inset 0 1px 0 hsl(0 0% 100% / 0.06), 0 8px 32px hsl(0 0% 0% / 0.5); + } +} + +/* Keyframes */ +@keyframes status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Global focus-visible styles */ +*:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + border-radius: var(--radius); +} + +*:focus:not(:focus-visible) { + outline: none; +} + +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: none; + box-shadow: 0 0 0 2px hsl(var(--background)), 0 0 0 4px hsl(var(--ring)); +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; } } diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 052b339..a7c08e5 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -1,41 +1,86 @@ import { Link } from '@tanstack/react-router' +import { Search } from 'lucide-react' +import { ThemeToggle } from '@/components/ThemeToggle' +import { HealthDot } from '@/components/HealthDot' +import { NavBadge } from '@/components/NavBadge' +import { trpc } from '@/lib/trpc' const navItems = [ - { label: 'Initiatives', to: '/initiatives' }, - { label: 'Agents', to: '/agents' }, - { label: 'Inbox', to: '/inbox' }, - { label: 'Settings', to: '/settings' }, + { label: 'Initiatives', to: '/initiatives', badgeKey: null }, + { label: 'Agents', to: '/agents', badgeKey: 'running' as const }, + { label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const }, + { label: 'Settings', to: '/settings', badgeKey: null }, ] as const -export function AppLayout({ children }: { children: React.ReactNode }) { +interface AppLayoutProps { + children: React.ReactNode + onOpenCommandPalette?: () => void +} + +export function AppLayout({ children, onOpenCommandPalette }: AppLayoutProps) { + const agents = trpc.listAgents.useQuery(undefined, { + refetchInterval: 10000, + }) + + const badgeCounts = { + running: agents.data?.filter((a) => a.status === 'running').length ?? 0, + questions: agents.data?.filter((a) => a.status === 'waiting_for_input').length ?? 0, + } + return (
      - {/* Header */} -
      -
      - - Codewalk District - -
      - {/* Navigation */} - + +
      + + {/* Right: Cmd+K, Theme toggle, Health, Workspace */} +
      + {onOpenCommandPalette && ( + + )} + + +
      +
      - {/* Page content */} -
      + {/* Page content — no max-width here, pages control their own */} +
      {children}
      diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx new file mode 100644 index 0000000..04c03f3 --- /dev/null +++ b/apps/web/src/lib/theme.tsx @@ -0,0 +1,54 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; + +export type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextValue { + theme: Theme; + setTheme: (t: Theme) => void; + isDark: boolean; +} + +const ThemeContext = createContext(null!); + +function applyTheme(theme: Theme) { + const isDark = + theme === 'dark' || + (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.toggle('dark', isDark); + localStorage.setItem('cw-theme', theme); +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState( + () => (localStorage.getItem('cw-theme') as Theme) || 'system' + ); + + const [systemDark, setSystemDark] = useState( + () => window.matchMedia('(prefers-color-scheme: dark)').matches + ); + + const isDark = theme === 'dark' || (theme === 'system' && systemDark); + + const setTheme = useCallback((t: Theme) => { + setThemeState(t); + applyTheme(t); + }, []); + + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e: MediaQueryListEvent) => { + setSystemDark(e.matches); + if (theme === 'system') applyTheme('system'); + }; + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [theme]); + + return ( + + {children} + + ); +} + +export const useTheme = () => useContext(ThemeContext); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 0088ceb..50edb94 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { trpc, createTRPCClient } from './lib/trpc'; import { createMutationCache } from './lib/invalidation'; +import { ThemeProvider } from './lib/theme'; +import { TooltipProvider } from './components/ui/tooltip'; import App from './App'; import './index.css'; @@ -26,11 +28,15 @@ function Root() { const [trpcClient] = useState(createTRPCClient); return ( - - - - - + + + + + + + + + ); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c2bcc3d..5214722 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,20 +1,45 @@ +import { useState, useCallback } from 'react' import { createRootRoute, Link, Outlet } from '@tanstack/react-router' import { AppLayout } from '../layouts/AppLayout' import { ErrorBoundary } from '../components/ErrorBoundary' import { Toaster } from '../components/ui/sonner' import { Button } from '../components/ui/button' +import { CommandPalette } from '../components/CommandPalette' +import { ConnectionBanner } from '../components/ConnectionBanner' +import { BrowserTitleUpdater } from '../components/BrowserTitleUpdater' +import { useConnectionStatus } from '../hooks/useConnectionStatus' +import { useGlobalKeyboard } from '../hooks/useGlobalKeyboard' -export const Route = createRootRoute({ - component: () => ( +function RootLayout() { + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) + const connectionState = useConnectionStatus() + + const openCommandPalette = useCallback(() => { + setCommandPaletteOpen(true) + }, []) + + useGlobalKeyboard({ onCommandK: openCommandPalette }) + + return ( <> - + + + setCommandPaletteOpen(false)} + /> + - ), + ) +} + +export const Route = createRootRoute({ + component: RootLayout, notFoundComponent: () => (

      Page not found

      diff --git a/apps/web/src/routes/agents.tsx b/apps/web/src/routes/agents.tsx index 7311941..260d2d6 100644 --- a/apps/web/src/routes/agents.tsx +++ b/apps/web/src/routes/agents.tsx @@ -264,7 +264,7 @@ function AgentsPage() { {agent.status === "waiting_for_input" && ( { e.stopPropagation(); handleGoToInbox(); diff --git a/apps/web/src/routes/initiatives/index.tsx b/apps/web/src/routes/initiatives/index.tsx index 9f567ee..368cb03 100644 --- a/apps/web/src/routes/initiatives/index.tsx +++ b/apps/web/src/routes/initiatives/index.tsx @@ -31,7 +31,7 @@ function DashboardPage() { ]); return ( -
      +
      {/* Page header */}

      Initiatives

      diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index d418c8e..824b2ee 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -11,7 +11,7 @@ const settingsTabs = [ function SettingsLayout() { return ( -
      +

      Settings

      diff --git a/apps/web/src/routes/settings/health.tsx b/apps/web/src/routes/settings/health.tsx index cd3d297..d0a61b2 100644 --- a/apps/web/src/routes/settings/health.tsx +++ b/apps/web/src/routes/settings/health.tsx @@ -93,7 +93,7 @@ function HealthCheckPage() {
      - +

      Running

      @@ -149,7 +149,7 @@ function HealthCheckPage() { {project.repoExists ? ( - + ) : ( )} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 4226992..4305b76 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -1,6 +1,7 @@ import type { Config } from "tailwindcss"; import tailwindcssAnimate from "tailwindcss-animate"; import typography from "@tailwindcss/typography"; +import defaultTheme from "tailwindcss/defaultTheme"; export default { darkMode: "class", @@ -14,6 +15,10 @@ export default { }, }, extend: { + fontFamily: { + sans: ["Geist Sans", ...defaultTheme.fontFamily.sans], + mono: ["Geist Mono", ...defaultTheme.fontFamily.mono], + }, colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", @@ -48,12 +53,104 @@ export default { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, + indigo: { + 50: "hsl(var(--indigo-50))", + 100: "hsl(var(--indigo-100))", + 200: "hsl(var(--indigo-200))", + 300: "hsl(var(--indigo-300))", + 400: "hsl(var(--indigo-400))", + 500: "hsl(var(--indigo-500))", + 600: "hsl(var(--indigo-600))", + 700: "hsl(var(--indigo-700))", + 800: "hsl(var(--indigo-800))", + 900: "hsl(var(--indigo-900))", + }, + status: { + "active-bg": "hsl(var(--status-active-bg))", + "active-fg": "hsl(var(--status-active-fg))", + "active-border": "hsl(var(--status-active-border))", + "active-dot": "hsl(var(--status-active-dot))", + "success-bg": "hsl(var(--status-success-bg))", + "success-fg": "hsl(var(--status-success-fg))", + "success-border": "hsl(var(--status-success-border))", + "success-dot": "hsl(var(--status-success-dot))", + "warning-bg": "hsl(var(--status-warning-bg))", + "warning-fg": "hsl(var(--status-warning-fg))", + "warning-border": "hsl(var(--status-warning-border))", + "warning-dot": "hsl(var(--status-warning-dot))", + "error-bg": "hsl(var(--status-error-bg))", + "error-fg": "hsl(var(--status-error-fg))", + "error-border": "hsl(var(--status-error-border))", + "error-dot": "hsl(var(--status-error-dot))", + "neutral-bg": "hsl(var(--status-neutral-bg))", + "neutral-fg": "hsl(var(--status-neutral-fg))", + "neutral-border": "hsl(var(--status-neutral-border))", + "neutral-dot": "hsl(var(--status-neutral-dot))", + "urgent-bg": "hsl(var(--status-urgent-bg))", + "urgent-fg": "hsl(var(--status-urgent-fg))", + "urgent-border": "hsl(var(--status-urgent-border))", + "urgent-dot": "hsl(var(--status-urgent-dot))", + }, + terminal: { + DEFAULT: "hsl(var(--terminal-bg))", + fg: "hsl(var(--terminal-fg))", + muted: "hsl(var(--terminal-muted))", + border: "hsl(var(--terminal-border))", + system: "hsl(var(--terminal-system))", + tool: "hsl(var(--terminal-tool))", + result: "hsl(var(--terminal-result))", + error: "hsl(var(--terminal-error))", + cursor: "hsl(var(--terminal-cursor))", + link: "hsl(var(--terminal-link))", + warning: "hsl(var(--terminal-warning))", + "line-number": "hsl(var(--terminal-line-number))", + }, + diff: { + "add-bg": "hsl(var(--diff-add-bg))", + "add-fg": "hsl(var(--diff-add-fg))", + "add-border": "hsl(var(--diff-add-border))", + "remove-bg": "hsl(var(--diff-remove-bg))", + "remove-fg": "hsl(var(--diff-remove-fg))", + "remove-border":"hsl(var(--diff-remove-border))", + "hunk-bg": "hsl(var(--diff-hunk-bg))", + }, }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, + boxShadow: { + xs: "var(--shadow-xs)", + sm: "var(--shadow-sm)", + md: "var(--shadow-md)", + lg: "var(--shadow-lg)", + xl: "var(--shadow-xl)", + }, + transitionDuration: { + instant: "var(--duration-instant)", + fast: "var(--duration-fast)", + normal: "var(--duration-normal)", + slow: "var(--duration-slow)", + }, + transitionTimingFunction: { + default: "var(--ease-default)", + in: "var(--ease-in)", + out: "var(--ease-out)", + spring: "var(--ease-spring)", + }, + zIndex: { + base: "var(--z-base)", + raised: "var(--z-raised)", + sticky: "var(--z-sticky)", + sidebar: "var(--z-sidebar)", + dropdown: "var(--z-dropdown)", + overlay: "var(--z-overlay)", + modal: "var(--z-modal)", + toast: "var(--z-toast)", + command: "var(--z-command)", + tooltip: "var(--z-tooltip)", + }, keyframes: { "accordion-down": { from: { height: "0" }, @@ -63,10 +160,20 @@ export default { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + "status-pulse": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.4" }, + }, + shimmer: { + "0%": { backgroundPosition: "-200% 0" }, + "100%": { backgroundPosition: "200% 0" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "status-pulse": "status-pulse 2s ease-in-out infinite", + shimmer: "shimmer 1.5s infinite", }, }, }, diff --git a/docs/frontend.md b/docs/frontend.md index 5e64267..7b320a7 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -10,9 +10,29 @@ | TanStack Router | File-based routing | | tRPC React Query | Type-safe API client with caching | | Tailwind CSS | Utility-first styling | -| shadcn/ui | Component library (button, card, dialog, dropdown, input, label, textarea, badge, sonner) | +| shadcn/ui | Component library (button, card, dialog, dropdown, input, label, textarea, badge, sonner, tooltip) | | Tiptap | Rich text editor (ProseMirror-based) | | Lucide | Icon library | +| Geist Sans/Mono | Typography (variable fonts in `public/fonts/`) | + +## Design System (v2) + +Theme spec: `docs/wireframes/v2/theme.md` + +- **Brand**: Indigo (#6366F1) — `--primary` is indigo, not black +- **Dark mode**: 3-state toggle (light/system/dark), persisted in `localStorage('cw-theme')` +- **Status tokens**: 6 semantic statuses (active/success/warning/error/neutral/urgent) with bg/fg/border/dot variants. Use `bg-status-{status}-bg`, `text-status-{status}-fg`, etc. +- **Terminal tokens**: Always-dark surface for agent output. Use `bg-terminal`, `text-terminal-fg`, etc. +- **Diff tokens**: `bg-diff-add-bg`, `text-diff-remove-fg`, etc. +- **Shadows**: 5-level system (xs-xl). Dark mode uses inset highlights + ambient glow. +- **Transitions**: `duration-fast`, `duration-normal`, `ease-default`, `ease-spring` +- **Z-index**: Named scale from `z-base` to `z-tooltip` +- **Radius**: 6px base (down from 8px) +- **Flash prevention**: Inline `