From affce2aba89d861982ce2537ac55f013d5bbba64 Mon Sep 17 00:00:00 2001 From: "Ishaan (OpenClaw)" Date: Sat, 7 Feb 2026 07:15:53 +0000 Subject: [PATCH 1/5] chore(compose): default POSTGRES_DB to mission_control --- .env.example | 2 +- backend/.env.example | 2 +- compose.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index d24078a..03da5e7 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ FRONTEND_PORT=3000 BACKEND_PORT=8000 # --- database --- -POSTGRES_DB=openclaw_agency +POSTGRES_DB=mission_control POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_PORT=5432 diff --git a/backend/.env.example b/backend/.env.example index e037de2..0905d73 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,6 @@ ENVIRONMENT=dev LOG_LEVEL=INFO -DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency +DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control REDIS_URL=redis://localhost:6379/0 CORS_ORIGINS=http://localhost:3000 BASE_URL= diff --git a/compose.yml b/compose.yml index f6a43a9..14ab19d 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: db: image: postgres:16-alpine environment: - POSTGRES_DB: ${POSTGRES_DB:-openclaw_agency} + POSTGRES_DB: ${POSTGRES_DB:-mission_control} POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} volumes: @@ -36,7 +36,7 @@ services: - ./backend/.env.example environment: # Override localhost defaults for container networking - DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-openclaw_agency} + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control} REDIS_URL: redis://redis:6379/0 CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true} From 26787d64fd4da278c6de733b5ecfc2d8e7b099b5 Mon Sep 17 00:00:00 2001 From: "Ishaan (OpenClaw)" Date: Sat, 7 Feb 2026 08:09:46 +0000 Subject: [PATCH 2/5] fix(frontend): use Clerk-safe auth wrapper on activity page --- frontend/src/app/activity/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 88167d9..2b2c30a 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { ArrowUpRight, Activity as ActivityIcon } from "lucide-react"; import { ApiError } from "@/api/mutator"; From 3079a92492eb0f651a3fa1378d3e36b9f0bdec53 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 12:45:02 +0530 Subject: [PATCH 3/5] feat(board): implement usePageActive hook to manage session refresh based on tab visibility --- frontend/src/app/activity/page.tsx | 5 ++- frontend/src/app/boards/[boardId]/page.tsx | 15 +++++-- .../src/components/BoardOnboardingChat.tsx | 11 ++++- frontend/src/hooks/usePageActive.ts | 42 +++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks/usePageActive.ts diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 2b2c30a..73257eb 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button"; import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; +import { usePageActive } from "@/hooks/usePageActive"; const SSE_RECONNECT_BACKOFF = { baseMs: 1_000, @@ -132,6 +133,7 @@ FeedCard.displayName = "FeedCard"; export default function ActivityPage() { const { isSignedIn } = useAuth(); + const isPageActive = usePageActive(); const feedQuery = useListTaskCommentFeedApiV1ActivityTaskCommentsGet< listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse, @@ -189,6 +191,7 @@ export default function ActivityPage() { }, []); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn) return; let isCancelled = false; const abortController = new AbortController(); @@ -278,7 +281,7 @@ export default function ActivityPage() { window.clearTimeout(reconnectTimeout); } }; - }, [isSignedIn, pushFeedItem]); + }, [isPageActive, isSignedIn, pushFeedItem]); const orderedFeed = useMemo(() => { return [...feedItems].sort((a, b) => { diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 1725560..b0e5f1f 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -72,6 +72,7 @@ import type { import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; +import { usePageActive } from "@/hooks/usePageActive"; type Board = BoardRead; @@ -298,6 +299,7 @@ export default function BoardDetailPage() { const boardIdParam = params?.boardId; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; const { isSignedIn } = useAuth(); + const isPageActive = usePageActive(); const taskIdFromUrl = searchParams.get("taskId"); const [board, setBoard] = useState(null); @@ -580,7 +582,9 @@ export default function BoardDetailPage() { }; useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; + if (!isChatOpen) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); @@ -685,9 +689,10 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isChatOpen, isPageActive, isSignedIn]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); @@ -817,7 +822,7 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isPageActive, isSignedIn]); useEffect(() => { if (!selectedTask) { @@ -840,6 +845,7 @@ export default function BoardDetailPage() { }, [selectedTask]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); @@ -1003,9 +1009,10 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn, pushLiveFeed]); + }, [board, boardId, isPageActive, isSignedIn, pushLiveFeed]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId) return; let isCancelled = false; const abortController = new AbortController(); @@ -1109,7 +1116,7 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isPageActive, isSignedIn]); const resetForm = () => { setTitle(""); diff --git a/frontend/src/components/BoardOnboardingChat.tsx b/frontend/src/components/BoardOnboardingChat.tsx index 5d4e006..512cbc2 100644 --- a/frontend/src/components/BoardOnboardingChat.tsx +++ b/frontend/src/components/BoardOnboardingChat.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { usePageActive } from "@/hooks/usePageActive"; import { answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost, @@ -116,6 +117,7 @@ export function BoardOnboardingChat({ boardId: string; onConfirmed: (board: BoardRead) => void; }) { + const isPageActive = usePageActive(); const [session, setSession] = useState(null); const [loading, setLoading] = useState(false); const [otherText, setOtherText] = useState(""); @@ -180,10 +182,15 @@ export function BoardOnboardingChat({ }, [boardId]); useEffect(() => { - startSession(); + void startSession(); + }, [startSession]); + + useEffect(() => { + if (!isPageActive) return; + void refreshSession(); const interval = setInterval(refreshSession, 2000); return () => clearInterval(interval); - }, [startSession, refreshSession]); + }, [isPageActive, refreshSession]); const handleAnswer = useCallback( async (value: string, freeText?: string) => { diff --git a/frontend/src/hooks/usePageActive.ts b/frontend/src/hooks/usePageActive.ts new file mode 100644 index 0000000..1c7da12 --- /dev/null +++ b/frontend/src/hooks/usePageActive.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const computeIsActive = () => { + if (typeof document === "undefined") return true; + const visible = + document.visibilityState === "visible" && + // `hidden` is a more widely-supported signal; keep both for safety. + !document.hidden; + const focused = typeof document.hasFocus === "function" ? document.hasFocus() : true; + return visible && focused; +}; + +/** + * Returns true when this tab/window is both visible and focused. + * + * Rationale: background tabs/windows should not keep long-lived connections + * (SSE/polling), otherwise opening multiple tabs can exhaust per-origin + * connection limits and make the app feel "hung". + */ +export function usePageActive(): boolean { + const [active, setActive] = useState(() => computeIsActive()); + + useEffect(() => { + const update = () => setActive(computeIsActive()); + + update(); + document.addEventListener("visibilitychange", update); + window.addEventListener("focus", update); + window.addEventListener("blur", update); + + return () => { + document.removeEventListener("visibilitychange", update); + window.removeEventListener("focus", update); + window.removeEventListener("blur", update); + }; + }, []); + + return active; +} + From a7175d9a6fcdc23807ebed5fc908da0a75cb4941 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 13:30:15 +0530 Subject: [PATCH 4/5] feat(board): add agent control dialog to pause and resume agents --- backend/app/api/board_memory.py | 28 ++- frontend/src/app/boards/[boardId]/page.tsx | 194 +++++++++++++++++++-- 2 files changed, 203 insertions(+), 19 deletions(-) diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py index d60f477..e861ba1 100644 --- a/backend/app/api/board_memory.py +++ b/backend/app/api/board_memory.py @@ -65,9 +65,10 @@ async def _send_agent_message( config: GatewayClientConfig, agent_name: str, message: str, + deliver: bool = False, ) -> None: await ensure_session(session_key, config=config, label=agent_name) - await send_message(message, session_key=session_key, config=config, deliver=False) + await send_message(message, session_key=session_key, config=config, deliver=deliver) async def _fetch_memory_events( @@ -102,6 +103,31 @@ async def _notify_chat_targets( config = await _gateway_config(session, board) if config is None: return + + normalized = memory.content.strip() + command = normalized.lower() + # Special-case control commands to reach all board agents. + # These are intended to be parsed verbatim by agent runtimes. + if command in {"/pause", "/resume"}: + statement = select(Agent).where(col(Agent.board_id) == board.id) + targets = list(await session.exec(statement)) + for agent in targets: + if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id: + continue + if not agent.openclaw_session_id: + continue + try: + await _send_agent_message( + session_key=agent.openclaw_session_id, + config=config, + agent_name=agent.name, + message=command, + deliver=True, + ) + except OpenClawGatewayError: + continue + return + mentions = extract_mentions(memory.content) statement = select(Agent).where(col(Agent.board_id) == board.id) targets: dict[str, Agent] = {} diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index b0e5f1f..84f3789 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -10,8 +10,12 @@ import { Activity, ArrowUpRight, MessageSquare, + Pause, + Plus, Pencil, + Play, Settings, + ShieldCheck, X, } from "lucide-react"; @@ -342,6 +346,15 @@ export default function BoardDetailPage() { const [chatError, setChatError] = useState(null); const chatMessagesRef = useRef([]); const chatEndRef = useRef(null); + const [isAgentsControlDialogOpen, setIsAgentsControlDialogOpen] = + useState(false); + const [agentsControlAction, setAgentsControlAction] = useState< + "pause" | "resume" + >("pause"); + const [isAgentsControlSending, setIsAgentsControlSending] = useState(false); + const [agentsControlError, setAgentsControlError] = useState( + null, + ); const [isDeletingTask, setIsDeletingTask] = useState(false); const [deleteTaskError, setDeleteTaskError] = useState(null); const [viewMode, setViewMode] = useState<"board" | "list">("board"); @@ -581,6 +594,18 @@ export default function BoardDetailPage() { return new Date(latest).toISOString(); }; + const lastAgentControlCommand = useMemo(() => { + for (let i = chatMessages.length - 1; i >= 0; i -= 1) { + const value = (chatMessages[i]?.content ?? "").trim().toLowerCase(); + if (value === "/pause" || value === "/resume") { + return value; + } + } + return null; + }, [chatMessages]); + + const isAgentsPaused = lastAgentControlCommand === "/pause"; + useEffect(() => { if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; @@ -1163,13 +1188,16 @@ export default function BoardDetailPage() { } }; - const handleSendChat = useCallback( - async (content: string): Promise => { - if (!isSignedIn || !boardId) return false; + const postBoardChatMessage = useCallback( + async ( + content: string, + ): Promise<{ ok: boolean; error: string | null }> => { + if (!isSignedIn || !boardId) { + return { ok: false, error: "Sign in to send messages." }; + } const trimmed = content.trim(); - if (!trimmed) return false; - setIsChatSending(true); - setChatError(null); + if (!trimmed) return { ok: false, error: null }; + try { const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost( boardId, @@ -1195,19 +1223,62 @@ export default function BoardDetailPage() { return next; }); } - return true; + return { ok: true, error: null }; } catch (err) { - setChatError( - err instanceof Error ? err.message : "Unable to send message.", - ); - return false; - } finally { - setIsChatSending(false); + const message = + err instanceof Error ? err.message : "Unable to send message."; + return { ok: false, error: message }; } }, [boardId, isSignedIn], ); + const handleSendChat = useCallback( + async (content: string): Promise => { + const trimmed = content.trim(); + if (!trimmed) return false; + setIsChatSending(true); + setChatError(null); + try { + const result = await postBoardChatMessage(trimmed); + if (!result.ok) { + if (result.error) { + setChatError(result.error); + } + return false; + } + return true; + } finally { + setIsChatSending(false); + } + }, + [postBoardChatMessage], + ); + + const openAgentsControlDialog = (action: "pause" | "resume") => { + setAgentsControlAction(action); + setAgentsControlError(null); + setIsAgentsControlDialogOpen(true); + }; + + const handleConfirmAgentsControl = useCallback(async () => { + const command = agentsControlAction === "pause" ? "/pause" : "/resume"; + setIsAgentsControlSending(true); + setAgentsControlError(null); + try { + const result = await postBoardChatMessage(command); + if (!result.ok) { + setAgentsControlError( + result.error ?? `Unable to send ${command} command.`, + ); + return; + } + setIsAgentsControlDialogOpen(false); + } finally { + setIsAgentsControlSending(false); + } + }, [agentsControlAction, postBoardChatMessage]); + const assigneeById = useMemo(() => { const map = new Map(); agents @@ -1869,21 +1940,48 @@ export default function BoardDetailPage() { List - + + + + + + {/* onboarding moved to board settings */} ); From 2c13c5b5ce8245f2b6c100746322e4f8f3c6089c Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 14:01:04 +0530 Subject: [PATCH 5/5] feat(landing): enhance landing page with new enterprise design and features --- frontend/src/app/globals.css | 836 ++++++++++++++++++ frontend/src/app/layout.tsx | 11 +- .../src/components/molecules/HeroCopy.tsx | 16 +- .../src/components/organisms/LandingHero.tsx | 334 +++++-- .../src/components/organisms/UserMenu.tsx | 107 ++- .../src/components/templates/LandingShell.tsx | 166 +++- frontend/tailwind.config.cjs | 1 + 7 files changed, 1341 insertions(+), 130 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d34394b..04f3795 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -132,3 +132,839 @@ body { .landing-page { font-family: var(--font-body), sans-serif; } + +/* Landing (Enterprise) */ +.landing-enterprise { + --primary-navy: #0a1628; + --secondary-navy: #1a2942; + --accent-gold: #d4af37; + --accent-teal: #2dd4bf; + --neutral-100: #f8fafb; + --neutral-200: #e5e9ed; + --neutral-300: #cbd2d9; + --neutral-700: #3e4c59; + --neutral-800: #1e293b; + --success: #10b981; + --warning: #f59e0b; + + min-height: 100vh; + font-family: var(--font-body), -apple-system, sans-serif; + background: var(--neutral-100); + color: var(--neutral-800); + line-height: 1.6; + overflow-x: hidden; +} + +@keyframes landing-slide-down { + from { + transform: translateY(-100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes landing-fade-in-up { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes landing-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.1); + } +} + +.landing-enterprise .landing-nav { + position: fixed; + top: 0; + width: 100%; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--neutral-200); + z-index: 40; + animation: landing-slide-down 0.6s ease-out; +} + +.landing-enterprise .nav-container { + max-width: 1400px; + margin: 0 auto; + padding: 1.25rem 2.5rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.5rem; +} + +.landing-enterprise .logo-section { + display: flex; + align-items: center; + gap: 0.75rem; + text-decoration: none; +} + +.landing-enterprise .logo-icon { + width: 36px; + height: 36px; + background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy)); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 18px; + box-shadow: 0 2px 8px rgba(10, 22, 40, 0.15); +} + +.landing-enterprise .logo-text { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.landing-enterprise .logo-name { + font-weight: 600; + font-size: 20px; + letter-spacing: -0.02em; + color: var(--primary-navy); +} + +.landing-enterprise .logo-tagline { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--neutral-700); + font-weight: 500; +} + +.landing-enterprise .nav-links { + display: flex; + gap: 2.5rem; + align-items: center; +} + +.landing-enterprise .nav-links a { + color: var(--neutral-700); + text-decoration: none; + font-weight: 500; + font-size: 15px; + transition: color 0.3s ease; + letter-spacing: -0.01em; +} + +.landing-enterprise .nav-links a:hover { + color: var(--primary-navy); +} + +.landing-enterprise .nav-cta { + display: flex; + gap: 1rem; + align-items: center; +} + +.landing-enterprise .btn-secondary { + padding: 0.625rem 1.25rem; + border: 1.5px solid var(--neutral-300); + background: white; + color: var(--neutral-800); + border-radius: 8px; + font-weight: 500; + font-size: 14px; + cursor: pointer; + transition: all 0.3s ease; +} + +.landing-enterprise .btn-secondary:hover { + border-color: var(--primary-navy); + background: var(--neutral-100); +} + +.landing-enterprise .btn-primary { + padding: 0.625rem 1.5rem; + background: var(--primary-navy); + color: white; + border: none; + border-radius: 8px; + font-weight: 500; + font-size: 14px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(10, 22, 40, 0.15); +} + +.landing-enterprise .btn-primary:hover { + background: var(--secondary-navy); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(10, 22, 40, 0.2); +} + +.landing-enterprise .hero { + margin-top: 80px; + padding: 6rem 2.5rem 4rem; + max-width: 1400px; + margin-left: auto; + margin-right: auto; + display: grid; + grid-template-columns: 1fr; + gap: 4rem; + align-items: center; +} + +.landing-enterprise .hero-content { + animation: landing-fade-in-up 0.8s ease-out 0.2s both; +} + +.landing-enterprise .hero-label { + display: inline-block; + padding: 0.5rem 1rem; + background: linear-gradient(135deg, rgba(10, 22, 40, 0.05), rgba(45, 212, 191, 0.08)); + border: 1px solid rgba(45, 212, 191, 0.2); + border-radius: 50px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--accent-teal); + margin-bottom: 1.5rem; +} + +.landing-enterprise .hero h1 { + font-family: var(--font-display), serif; + font-size: 56px; + line-height: 1.15; + color: var(--primary-navy); + margin-bottom: 1.5rem; + font-weight: 400; + letter-spacing: -0.02em; +} + +.landing-enterprise .hero-highlight { + background: linear-gradient(135deg, var(--accent-teal), var(--accent-gold)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-style: italic; +} + +.landing-enterprise .hero p { + font-size: 19px; + line-height: 1.7; + color: var(--neutral-700); + margin-bottom: 2.5rem; + font-weight: 400; +} + +.landing-enterprise .hero-actions { + display: flex; + gap: 1rem; + margin-bottom: 3rem; +} + +.landing-enterprise .btn-large { + padding: 1rem 2rem; + font-size: 16px; + font-weight: 500; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; +} + +.landing-enterprise .btn-large.primary { + background: var(--primary-navy); + color: white; + border: none; + box-shadow: 0 4px 12px rgba(10, 22, 40, 0.2); +} + +.landing-enterprise .btn-large.primary:hover { + background: var(--secondary-navy); + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(10, 22, 40, 0.25); +} + +.landing-enterprise .btn-large.secondary { + background: white; + color: var(--neutral-800); + border: 1.5px solid var(--neutral-300); +} + +.landing-enterprise .btn-large.secondary:hover { + border-color: var(--primary-navy); + background: var(--neutral-100); +} + +.landing-enterprise .hero-features { + display: flex; + gap: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--neutral-200); +} + +.landing-enterprise .hero-feature { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.landing-enterprise .feature-icon { + width: 20px; + height: 20px; + background: var(--accent-teal); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + flex-shrink: 0; +} + +.landing-enterprise .hero-feature span { + font-size: 14px; + font-weight: 500; + color: var(--neutral-700); +} + +.landing-enterprise .command-surface { + background: white; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(10, 22, 40, 0.08); + overflow: hidden; + animation: landing-fade-in-up 0.8s ease-out 0.4s both; + border: 1px solid var(--neutral-200); +} + +.landing-enterprise .surface-header { + padding: 1.5rem 2rem; + background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy)); + color: white; + display: flex; + justify-content: space-between; + align-items: center; +} + +.landing-enterprise .surface-title { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 600; + opacity: 0.9; +} + +.landing-enterprise .live-indicator { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 12px; + font-weight: 600; +} + +.landing-enterprise .live-dot { + width: 8px; + height: 8px; + background: var(--accent-teal); + border-radius: 50%; + animation: landing-pulse 2s infinite; +} + +.landing-enterprise .surface-subtitle { + padding: 1.25rem 2rem; + background: var(--neutral-100); + border-bottom: 1px solid var(--neutral-200); +} + +.landing-enterprise .surface-subtitle h3 { + font-size: 16px; + font-weight: 600; + color: var(--primary-navy); + margin-bottom: 0.25rem; +} + +.landing-enterprise .surface-subtitle p { + font-size: 13px; + color: var(--neutral-700); +} + +.landing-enterprise .metrics-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + border-bottom: 1px solid var(--neutral-200); +} + +.landing-enterprise .metric { + padding: 1.75rem 2rem; + text-align: center; + border-right: 1px solid var(--neutral-200); +} + +.landing-enterprise .metric:last-child { + border-right: none; +} + +.landing-enterprise .metric-value { + font-size: 36px; + font-weight: 300; + color: var(--primary-navy); + letter-spacing: -0.02em; + margin-bottom: 0.25rem; +} + +.landing-enterprise .metric-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--neutral-700); + font-weight: 600; +} + +.landing-enterprise .surface-content { + padding: 2rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; +} + +.landing-enterprise .content-section h4 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--neutral-700); + font-weight: 600; + margin-bottom: 1rem; +} + +.landing-enterprise .status-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--neutral-200); +} + +.landing-enterprise .status-item:last-child { + border-bottom: none; +} + +.landing-enterprise .status-icon { + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 14px; +} + +.landing-enterprise .status-icon.progress { + background: rgba(45, 212, 191, 0.1); + color: var(--accent-teal); +} + +.landing-enterprise .status-item-title { + font-size: 14px; + font-weight: 500; + color: var(--primary-navy); +} + +.landing-enterprise .approval-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.875rem 0; + border-bottom: 1px solid var(--neutral-200); + gap: 0.75rem; +} + +.landing-enterprise .approval-item:last-child { + border-bottom: none; +} + +.landing-enterprise .approval-title { + font-size: 14px; + color: var(--neutral-800); + font-weight: 500; +} + +.landing-enterprise .approval-badge { + padding: 0.35rem 0.75rem; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.landing-enterprise .approval-badge.ready { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.landing-enterprise .approval-badge.waiting { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); +} + +.landing-enterprise .signal-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid var(--neutral-200); + gap: 0.75rem; +} + +.landing-enterprise .signal-item:last-child { + border-bottom: none; +} + +.landing-enterprise .signal-text { + font-size: 13px; + color: var(--neutral-700); +} + +.landing-enterprise .signal-time { + font-size: 12px; + color: var(--neutral-700); + font-weight: 500; + white-space: nowrap; +} + +.landing-enterprise .features-section { + padding: 6rem 2.5rem; + max-width: 1400px; + margin: 0 auto; +} + +.landing-enterprise #capabilities { + scroll-margin-top: 110px; +} + +.landing-enterprise .features-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2rem; + margin-top: 3rem; +} + +.landing-enterprise .feature-card { + background: white; + padding: 2rem; + border-radius: 12px; + border: 1px solid var(--neutral-200); + transition: all 0.3s ease; + animation: landing-fade-in-up 0.6s ease-out both; +} + +.landing-enterprise .feature-card:nth-child(1) { + animation-delay: 0.1s; +} + +.landing-enterprise .feature-card:nth-child(2) { + animation-delay: 0.2s; +} + +.landing-enterprise .feature-card:nth-child(3) { + animation-delay: 0.3s; +} + +.landing-enterprise .feature-card:nth-child(4) { + animation-delay: 0.4s; +} + +.landing-enterprise .feature-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 32px rgba(10, 22, 40, 0.12); + border-color: var(--accent-teal); +} + +.landing-enterprise .feature-number { + width: 48px; + height: 48px; + background: linear-gradient(135deg, rgba(10, 22, 40, 0.05), rgba(45, 212, 191, 0.08)); + border: 1px solid var(--neutral-200); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + margin-bottom: 1.5rem; + color: var(--primary-navy); + font-weight: 300; +} + +.landing-enterprise .feature-card h3 { + font-size: 18px; + font-weight: 600; + color: var(--primary-navy); + margin-bottom: 0.75rem; + letter-spacing: -0.01em; +} + +.landing-enterprise .feature-card p { + font-size: 14px; + line-height: 1.6; + color: var(--neutral-700); +} + +.landing-enterprise .cta-section { + padding: 5rem 2.5rem; + background: linear-gradient(135deg, var(--primary-navy), var(--secondary-navy)); + text-align: center; +} + +.landing-enterprise .cta-content { + max-width: 800px; + margin: 0 auto; +} + +.landing-enterprise .cta-section h2 { + font-family: var(--font-display), serif; + font-size: 42px; + color: white; + margin-bottom: 1rem; + font-weight: 400; + letter-spacing: -0.01em; +} + +.landing-enterprise .cta-section p { + font-size: 18px; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 2.5rem; +} + +.landing-enterprise .cta-actions { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +.landing-enterprise .btn-large.white { + background: white; + color: var(--primary-navy); + border: none; +} + +.landing-enterprise .btn-large.white:hover { + background: var(--neutral-100); + transform: translateY(-2px); +} + +.landing-enterprise .btn-large.outline { + background: transparent; + color: white; + border: 1.5px solid rgba(255, 255, 255, 0.3); +} + +.landing-enterprise .btn-large.outline:hover { + background: rgba(255, 255, 255, 0.1); + border-color: white; +} + +.landing-enterprise .landing-footer { + background: var(--neutral-100); + border-top: 1px solid var(--neutral-200); + padding: 3rem 2.5rem 2rem; +} + +.landing-enterprise .footer-content { + max-width: 1400px; + margin: 0 auto; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 4rem; + margin-bottom: 3rem; +} + +.landing-enterprise .footer-brand h3 { + font-size: 18px; + font-weight: 600; + color: var(--primary-navy); + margin-bottom: 0.75rem; +} + +.landing-enterprise .footer-brand p { + font-size: 14px; + color: var(--neutral-700); + line-height: 1.6; + margin-bottom: 1.5rem; +} + +.landing-enterprise .footer-tagline { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--neutral-700); + font-weight: 600; +} + +.landing-enterprise .footer-column h4 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--neutral-700); + font-weight: 600; + margin-bottom: 1rem; +} + +.landing-enterprise .footer-links { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.landing-enterprise .footer-links a, +.landing-enterprise .footer-links button { + color: var(--neutral-700); + text-decoration: none; + font-size: 14px; + transition: color 0.3s ease; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + font-weight: 400; +} + +.landing-enterprise .footer-links a:hover, +.landing-enterprise .footer-links button:hover { + color: var(--primary-navy); +} + +.landing-enterprise .footer-bottom { + max-width: 1400px; + margin: 0 auto; + padding-top: 2rem; + border-top: 1px solid var(--neutral-200); + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.landing-enterprise .footer-copyright { + font-size: 13px; + color: var(--neutral-700); +} + +.landing-enterprise .footer-bottom-links { + display: flex; + gap: 2rem; + flex-wrap: wrap; +} + +.landing-enterprise .footer-bottom-links a { + font-size: 13px; + color: var(--neutral-700); + text-decoration: none; + transition: color 0.3s ease; +} + +.landing-enterprise .footer-bottom-links a:hover { + color: var(--primary-navy); +} + +@media (max-width: 1024px) { + .landing-enterprise .nav-container { + padding: 1rem 1.5rem; + } + + .landing-enterprise .nav-links { + display: none; + } + + .landing-enterprise .hero { + grid-template-columns: 1fr; + gap: 3rem; + } + + .landing-enterprise .features-grid { + grid-template-columns: repeat(2, 1fr); + } + + .landing-enterprise .footer-content { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 768px) { + .landing-enterprise .hero { + padding: 4.5rem 1.25rem 3rem; + } + + .landing-enterprise .hero h1 { + font-size: 40px; + } + + .landing-enterprise .hero-actions { + flex-direction: column; + } + + .landing-enterprise .btn-large { + width: 100%; + justify-content: center; + } + + .landing-enterprise .hero-features { + flex-direction: column; + gap: 1rem; + } + + .landing-enterprise .features-section { + padding: 4.5rem 1.25rem; + } + + .landing-enterprise .features-grid { + grid-template-columns: 1fr; + } + + .landing-enterprise .metrics-row { + grid-template-columns: 1fr; + } + + .landing-enterprise .metric { + border-right: none; + border-bottom: 1px solid var(--neutral-200); + } + + .landing-enterprise .metric:last-child { + border-bottom: none; + } + + .landing-enterprise .surface-content { + grid-template-columns: 1fr; + } + + .landing-enterprise .landing-footer { + padding: 2.5rem 1.25rem 2rem; + } + + .landing-enterprise .footer-content { + grid-template-columns: 1fr; + gap: 2rem; + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 905dfae..12028ad 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,7 +3,7 @@ import "./globals.css"; import type { Metadata } from "next"; import type { ReactNode } from "react"; -import { IBM_Plex_Sans, Sora } from "next/font/google"; +import { DM_Serif_Display, IBM_Plex_Sans, Sora } from "next/font/google"; import { AuthProvider } from "@/components/providers/AuthProvider"; import { QueryProvider } from "@/components/providers/QueryProvider"; @@ -27,11 +27,18 @@ const headingFont = Sora({ weight: ["500", "600", "700"], }); +const displayFont = DM_Serif_Display({ + subsets: ["latin"], + display: "swap", + variable: "--font-display", + weight: ["400"], +}); + export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} diff --git a/frontend/src/components/molecules/HeroCopy.tsx b/frontend/src/components/molecules/HeroCopy.tsx index b689234..0639c68 100644 --- a/frontend/src/components/molecules/HeroCopy.tsx +++ b/frontend/src/components/molecules/HeroCopy.tsx @@ -3,16 +3,22 @@ import { HeroKicker } from "@/components/atoms/HeroKicker"; export function HeroCopy() { return (
- Mission Control + OpenClaw Mission Control

- Enterprise control for + Command autonomous work.
- autonomous execution. + + Keep human oversight. +

- Coordinate boards, agents, and approvals in one command layer. No - status meetings. No blind spots. Just durable execution. + Track tasks, approvals, and agent health in one calm surface. Get + realtime signals when work changes, without chasing people for status.

diff --git a/frontend/src/components/organisms/LandingHero.tsx b/frontend/src/components/organisms/LandingHero.tsx index 349bd48..e9117fc 100644 --- a/frontend/src/components/organisms/LandingHero.tsx +++ b/frontend/src/components/organisms/LandingHero.tsx @@ -1,105 +1,269 @@ "use client"; -import { SignInButton, SignedIn, SignedOut } from "@/auth/clerk"; +import Link from "next/link"; -import { HeroCopy } from "@/components/molecules/HeroCopy"; -import { Button } from "@/components/ui/button"; +import { SignInButton, SignedIn, SignedOut, isClerkEnabled } from "@/auth/clerk"; + +const ArrowIcon = () => ( + +); export function LandingHero() { - return ( -
-
- -
- - - - - - -
- You're signed in. Open your boards when you're ready. -
-
-
-
- - Enterprise ready - - - Agent-first ops - - - 24/7 visibility - -
-
+ const clerkEnabled = isClerkEnabled(); -
-
-
- Command surface - - Live - + return ( + <> +
+
+
OpenClaw Mission Control
+

+ Command autonomous work. +
+ Keep human oversight. +

+

+ Track tasks, approvals, and agent health in one unified command + center. Get real-time signals when work changes, without losing the + thread of execution. +

+ +
+ + {clerkEnabled ? ( + <> + + + + + + + + ) : ( + <> + + Open Boards + + + Create Board + + + )} + + + + + Open Boards + + + Create Board + +
-
-
-

- Tasks claimed, tracked, delivered. -

-

- See every queue, agent, and handoff without chasing updates. -

+ +
+ {[ + "Agent-First Operations", + "Approval Queues", + "Live Signals", + ].map((label) => ( +
+
+ {label} +
+ ))} +
+
+ +
+
+
Command Surface
+
+
+ LIVE
-
+
+
+

Ship work without losing the thread.

+

Tasks, approvals, and agent status stay synced across the board.

+
+
+ {[ + { label: "Boards", value: "12" }, + { label: "Agents", value: "08" }, + { label: "Tasks", value: "46" }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+
+
+

Board — In Progress

{[ - { label: "Active boards", value: "12" }, - { label: "Agents live", value: "08" }, - { label: "Tasks in flow", value: "46" }, - ].map((item) => ( -
-
- {item.value} -
-
- {item.label} + "Cut release candidate", + "Triage approvals backlog", + "Stabilize agent handoffs", + ].map((title) => ( +
+
+
+
{title}
))}
-
-
- Signals - Updated 2m ago -
-
-
- Agent Delta moved task to review - Just now + +
+

Approvals — 3 Pending

+ {[ + { title: "Deploy window confirmed", status: "ready" as const }, + { title: "Copy reviewed", status: "waiting" as const }, + { title: "Security sign-off", status: "waiting" as const }, + ].map((item) => ( +
+
{item.title}
+
+ {item.status} +
-
- Board Growth Ops hit WIP limit - 5m + ))} +
+
+ +
+
+

Signals — Updated Moments Ago

+ {[ + { text: "Agent Delta moved task to review", time: "Now" }, + { text: "Growth Ops hit WIP limit", time: "5m" }, + { text: "Release pipeline stabilized", time: "12m" }, + ].map((signal) => ( +
+
{signal.text}
+
{signal.time}
-
- Release tasks stabilized - 12m -
-
+ ))}
-
-
+
+ +
+
+ {[ + { + title: "Boards as ops maps", + description: + "Keep tasks, priorities, dependencies, and ownership visible at a glance.", + }, + { + title: "Approvals that move", + description: + "Queue, comment, and approve without losing context or slowing execution.", + }, + { + title: "Realtime signals", + description: + "See work change as it happens: tasks, agent status, and approvals update live.", + }, + { + title: "Audit trail built in", + description: + "Every decision leaves a trail, so the board stays explainable and reviewable.", + }, + ].map((feature, idx) => ( +
+
+ {String(idx + 1).padStart(2, "0")} +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+ +
+
+

Start with one board. Grow into a control room.

+

+ Onboard a board, name a lead agent, and keep approvals and signals + visible from day one. +

+
+ + {clerkEnabled ? ( + <> + + + + + + + + ) : ( + <> + + Create Board + + + View Boards + + + )} + + + + + Create Board + + + View Boards + + +
+
+
+ ); } + diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx index 661fbea..c41f6f3 100644 --- a/frontend/src/components/organisms/UserMenu.tsx +++ b/frontend/src/components/organisms/UserMenu.tsx @@ -1,13 +1,25 @@ "use client"; import Image from "next/image"; +import Link from "next/link"; +import { useState } from "react"; import { SignOutButton, useUser } from "@/auth/clerk"; -import { LogOut } from "lucide-react"; +import { + Activity, + Bot, + ChevronDown, + LayoutDashboard, + LogOut, + Plus, + Server, + Trello, +} from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; export function UserMenu({ className }: { className?: string }) { + const [open, setOpen] = useState(false); const { user } = useUser(); if (!user) return null; @@ -19,39 +31,59 @@ export function UserMenu({ className }: { className?: string }) { const displayEmail = user.primaryEmailAddress?.emailAddress ?? ""; return ( - + -
+
- + {avatarUrl ? (
-
+
{displayName}
{displayEmail ? ( -
{displayEmail}
+
+ {displayEmail} +
) : null}
+
+ setOpen(false)} + > + + Open boards + + setOpen(false)} + > + + Create board + +
+ +
+ + {( + [ + { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/activity", label: "Activity", icon: Activity }, + { href: "/agents", label: "Agents", icon: Bot }, + { href: "/gateways", label: "Gateways", icon: Server }, + ] as const + ).map((item) => ( + setOpen(false)} + > + + {item.label} + + ))} + +
+ diff --git a/frontend/src/components/templates/LandingShell.tsx b/frontend/src/components/templates/LandingShell.tsx index 32d6af7..a63dfaa 100644 --- a/frontend/src/components/templates/LandingShell.tsx +++ b/frontend/src/components/templates/LandingShell.tsx @@ -1,39 +1,159 @@ "use client"; +import Link from "next/link"; import type { ReactNode } from "react"; -import { SignedIn } from "@/auth/clerk"; +import { SignInButton, SignedIn, SignedOut, isClerkEnabled } from "@/auth/clerk"; -import { BrandMark } from "@/components/atoms/BrandMark"; import { UserMenu } from "@/components/organisms/UserMenu"; export function LandingShell({ children }: { children: ReactNode }) { - return ( -
-
-
+ + +
{children}
+ +
); } diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index 232ec02..f46b047 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -7,6 +7,7 @@ module.exports = { fontFamily: { heading: ["var(--font-heading)", "sans-serif"], body: ["var(--font-body)", "sans-serif"], + display: ["var(--font-display)", "serif"], }, }, },