Merge remote-tracking branch 'origin/master' into ishan/ci-coverage-gate

This commit is contained in:
Ishan (OpenClaw)
2026-02-07 09:02:44 +00:00
15 changed files with 1615 additions and 161 deletions

View File

@@ -6,7 +6,7 @@ FRONTEND_PORT=3000
BACKEND_PORT=8000 BACKEND_PORT=8000
# --- database --- # --- database ---
POSTGRES_DB=openclaw_agency POSTGRES_DB=mission_control
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432

View File

@@ -1,6 +1,6 @@
ENVIRONMENT=dev ENVIRONMENT=dev
LOG_LEVEL=INFO 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 REDIS_URL=redis://localhost:6379/0
CORS_ORIGINS=http://localhost:3000 CORS_ORIGINS=http://localhost:3000
BASE_URL= BASE_URL=

View File

@@ -65,9 +65,10 @@ async def _send_agent_message(
config: GatewayClientConfig, config: GatewayClientConfig,
agent_name: str, agent_name: str,
message: str, message: str,
deliver: bool = False,
) -> None: ) -> None:
await ensure_session(session_key, config=config, label=agent_name) 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( async def _fetch_memory_events(
@@ -102,6 +103,31 @@ async def _notify_chat_targets(
config = await _gateway_config(session, board) config = await _gateway_config(session, board)
if config is None: if config is None:
return 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) mentions = extract_mentions(memory.content)
statement = select(Agent).where(col(Agent.board_id) == board.id) statement = select(Agent).where(col(Agent.board_id) == board.id)
targets: dict[str, Agent] = {} targets: dict[str, Agent] = {}

View File

@@ -4,7 +4,7 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-openclaw_agency} POSTGRES_DB: ${POSTGRES_DB:-mission_control}
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes: volumes:
@@ -36,7 +36,7 @@ services:
- ./backend/.env.example - ./backend/.env.example
environment: environment:
# Override localhost defaults for container networking # 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 REDIS_URL: redis://redis:6379/0
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true} DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}

View File

@@ -3,7 +3,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; 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 { ArrowUpRight, Activity as ActivityIcon } from "lucide-react";
import { ApiError } from "@/api/mutator"; import { ApiError } from "@/api/mutator";
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { createExponentialBackoff } from "@/lib/backoff"; import { createExponentialBackoff } from "@/lib/backoff";
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePageActive } from "@/hooks/usePageActive";
const SSE_RECONNECT_BACKOFF = { const SSE_RECONNECT_BACKOFF = {
baseMs: 1_000, baseMs: 1_000,
@@ -132,6 +133,7 @@ FeedCard.displayName = "FeedCard";
export default function ActivityPage() { export default function ActivityPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const isPageActive = usePageActive();
const feedQuery = useListTaskCommentFeedApiV1ActivityTaskCommentsGet< const feedQuery = useListTaskCommentFeedApiV1ActivityTaskCommentsGet<
listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse, listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse,
@@ -189,6 +191,7 @@ export default function ActivityPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn) return; if (!isSignedIn) return;
let isCancelled = false; let isCancelled = false;
const abortController = new AbortController(); const abortController = new AbortController();
@@ -278,7 +281,7 @@ export default function ActivityPage() {
window.clearTimeout(reconnectTimeout); window.clearTimeout(reconnectTimeout);
} }
}; };
}, [isSignedIn, pushFeedItem]); }, [isPageActive, isSignedIn, pushFeedItem]);
const orderedFeed = useMemo(() => { const orderedFeed = useMemo(() => {
return [...feedItems].sort((a, b) => { return [...feedItems].sort((a, b) => {

View File

@@ -10,8 +10,12 @@ import {
Activity, Activity,
ArrowUpRight, ArrowUpRight,
MessageSquare, MessageSquare,
Pause,
Plus,
Pencil, Pencil,
Play,
Settings, Settings,
ShieldCheck,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -72,6 +76,7 @@ import type {
import { createExponentialBackoff } from "@/lib/backoff"; import { createExponentialBackoff } from "@/lib/backoff";
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePageActive } from "@/hooks/usePageActive";
type Board = BoardRead; type Board = BoardRead;
@@ -298,6 +303,7 @@ export default function BoardDetailPage() {
const boardIdParam = params?.boardId; const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const isPageActive = usePageActive();
const taskIdFromUrl = searchParams.get("taskId"); const taskIdFromUrl = searchParams.get("taskId");
const [board, setBoard] = useState<Board | null>(null); const [board, setBoard] = useState<Board | null>(null);
@@ -340,6 +346,15 @@ export default function BoardDetailPage() {
const [chatError, setChatError] = useState<string | null>(null); const [chatError, setChatError] = useState<string | null>(null);
const chatMessagesRef = useRef<BoardChatMessage[]>([]); const chatMessagesRef = useRef<BoardChatMessage[]>([]);
const chatEndRef = useRef<HTMLDivElement | null>(null); const chatEndRef = useRef<HTMLDivElement | null>(null);
const [isAgentsControlDialogOpen, setIsAgentsControlDialogOpen] =
useState(false);
const [agentsControlAction, setAgentsControlAction] = useState<
"pause" | "resume"
>("pause");
const [isAgentsControlSending, setIsAgentsControlSending] = useState(false);
const [agentsControlError, setAgentsControlError] = useState<string | null>(
null,
);
const [isDeletingTask, setIsDeletingTask] = useState(false); const [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null); const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [viewMode, setViewMode] = useState<"board" | "list">("board");
@@ -579,8 +594,22 @@ export default function BoardDetailPage() {
return new Date(latest).toISOString(); 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(() => { useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return; if (!isSignedIn || !boardId || !board) return;
if (!isChatOpen) return;
let isCancelled = false; let isCancelled = false;
const abortController = new AbortController(); const abortController = new AbortController();
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
@@ -685,9 +714,10 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout); window.clearTimeout(reconnectTimeout);
} }
}; };
}, [board, boardId, isSignedIn]); }, [board, boardId, isChatOpen, isPageActive, isSignedIn]);
useEffect(() => { useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return; if (!isSignedIn || !boardId || !board) return;
let isCancelled = false; let isCancelled = false;
const abortController = new AbortController(); const abortController = new AbortController();
@@ -817,7 +847,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout); window.clearTimeout(reconnectTimeout);
} }
}; };
}, [board, boardId, isSignedIn]); }, [board, boardId, isPageActive, isSignedIn]);
useEffect(() => { useEffect(() => {
if (!selectedTask) { if (!selectedTask) {
@@ -840,6 +870,7 @@ export default function BoardDetailPage() {
}, [selectedTask]); }, [selectedTask]);
useEffect(() => { useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return; if (!isSignedIn || !boardId || !board) return;
let isCancelled = false; let isCancelled = false;
const abortController = new AbortController(); const abortController = new AbortController();
@@ -1003,9 +1034,10 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout); window.clearTimeout(reconnectTimeout);
} }
}; };
}, [board, boardId, isSignedIn, pushLiveFeed]); }, [board, boardId, isPageActive, isSignedIn, pushLiveFeed]);
useEffect(() => { useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
let isCancelled = false; let isCancelled = false;
const abortController = new AbortController(); const abortController = new AbortController();
@@ -1109,7 +1141,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout); window.clearTimeout(reconnectTimeout);
} }
}; };
}, [board, boardId, isSignedIn]); }, [board, boardId, isPageActive, isSignedIn]);
const resetForm = () => { const resetForm = () => {
setTitle(""); setTitle("");
@@ -1156,13 +1188,16 @@ export default function BoardDetailPage() {
} }
}; };
const handleSendChat = useCallback( const postBoardChatMessage = useCallback(
async (content: string): Promise<boolean> => { async (
if (!isSignedIn || !boardId) return false; content: string,
): Promise<{ ok: boolean; error: string | null }> => {
if (!isSignedIn || !boardId) {
return { ok: false, error: "Sign in to send messages." };
}
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) return false; if (!trimmed) return { ok: false, error: null };
setIsChatSending(true);
setChatError(null);
try { try {
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost( const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(
boardId, boardId,
@@ -1188,19 +1223,62 @@ export default function BoardDetailPage() {
return next; return next;
}); });
} }
return true; return { ok: true, error: null };
} catch (err) { } catch (err) {
setChatError( const message =
err instanceof Error ? err.message : "Unable to send message.", err instanceof Error ? err.message : "Unable to send message.";
); return { ok: false, error: message };
return false;
} finally {
setIsChatSending(false);
} }
}, },
[boardId, isSignedIn], [boardId, isSignedIn],
); );
const handleSendChat = useCallback(
async (content: string): Promise<boolean> => {
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 assigneeById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
agents agents
@@ -1862,21 +1940,48 @@ export default function BoardDetailPage() {
List List
</button> </button>
</div> </div>
<Button onClick={() => setIsDialogOpen(true)}> <Button
New task onClick={() => setIsDialogOpen(true)}
className="h-9 w-9 p-0"
aria-label="New task"
title="New task"
>
<Plus className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => router.push(`/boards/${boardId}/approvals`)} onClick={() => router.push(`/boards/${boardId}/approvals`)}
className="relative" className="relative h-9 w-9 p-0"
aria-label="Approvals"
title="Approvals"
> >
Approvals <ShieldCheck className="h-4 w-4" />
{pendingApprovals.length > 0 ? ( {pendingApprovals.length > 0 ? (
<span className="ml-2 inline-flex min-w-[20px] items-center justify-center rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white"> <span className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{pendingApprovals.length} {pendingApprovals.length}
</span> </span>
) : null} ) : null}
</Button> </Button>
<Button
variant="outline"
onClick={() =>
openAgentsControlDialog(
isAgentsPaused ? "resume" : "pause",
)
}
disabled={!isSignedIn || !boardId || isAgentsControlSending}
className={cn("h-9 w-9 p-0", isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "")}
aria-label={isAgentsPaused ? "Resume agents" : "Pause agents"}
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
>
{isAgentsPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={openBoardChat} onClick={openBoardChat}
@@ -2792,6 +2897,66 @@ export default function BoardDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog
open={isAgentsControlDialogOpen}
onOpenChange={(nextOpen) => {
setIsAgentsControlDialogOpen(nextOpen);
if (!nextOpen) {
setAgentsControlError(null);
}
}}
>
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause" ? "Pause agents" : "Resume agents"}
</DialogTitle>
<DialogDescription>
{agentsControlAction === "pause"
? "Send /pause to every agent on this board."
: "Send /resume to every agent on this board."}
</DialogDescription>
</DialogHeader>
{agentsControlError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{agentsControlError}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-semibold text-slate-900">What happens</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
This posts{" "}
<span className="font-mono">
{agentsControlAction === "pause" ? "/pause" : "/resume"}
</span>{" "}
to board chat.
</li>
<li>Mission Control forwards it to all agents on this board.</li>
</ul>
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsAgentsControlDialogOpen(false)}
disabled={isAgentsControlSending}
>
Cancel
</Button>
<Button onClick={handleConfirmAgentsControl} disabled={isAgentsControlSending}>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* onboarding moved to board settings */} {/* onboarding moved to board settings */}
</DashboardShell> </DashboardShell>
); );

View File

@@ -132,3 +132,839 @@ body {
.landing-page { .landing-page {
font-family: var(--font-body), sans-serif; 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;
}
}

View File

@@ -3,7 +3,7 @@ import "./globals.css";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react"; 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 { AuthProvider } from "@/components/providers/AuthProvider";
import { QueryProvider } from "@/components/providers/QueryProvider"; import { QueryProvider } from "@/components/providers/QueryProvider";
@@ -27,11 +27,18 @@ const headingFont = Sora({
weight: ["500", "600", "700"], 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 }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body <body
className={`${bodyFont.variable} ${headingFont.variable} min-h-screen bg-app text-strong antialiased`} className={`${bodyFont.variable} ${headingFont.variable} ${displayFont.variable} min-h-screen bg-app text-strong antialiased`}
> >
<AuthProvider> <AuthProvider>
<QueryProvider>{children}</QueryProvider> <QueryProvider>{children}</QueryProvider>

View File

@@ -9,6 +9,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { usePageActive } from "@/hooks/usePageActive";
import { import {
answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost, answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost,
@@ -116,6 +117,7 @@ export function BoardOnboardingChat({
boardId: string; boardId: string;
onConfirmed: (board: BoardRead) => void; onConfirmed: (board: BoardRead) => void;
}) { }) {
const isPageActive = usePageActive();
const [session, setSession] = useState<BoardOnboardingRead | null>(null); const [session, setSession] = useState<BoardOnboardingRead | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [otherText, setOtherText] = useState(""); const [otherText, setOtherText] = useState("");
@@ -180,10 +182,15 @@ export function BoardOnboardingChat({
}, [boardId]); }, [boardId]);
useEffect(() => { useEffect(() => {
startSession(); void startSession();
}, [startSession]);
useEffect(() => {
if (!isPageActive) return;
void refreshSession();
const interval = setInterval(refreshSession, 2000); const interval = setInterval(refreshSession, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [startSession, refreshSession]); }, [isPageActive, refreshSession]);
const handleAnswer = useCallback( const handleAnswer = useCallback(
async (value: string, freeText?: string) => { async (value: string, freeText?: string) => {

View File

@@ -3,16 +3,22 @@ import { HeroKicker } from "@/components/atoms/HeroKicker";
export function HeroCopy() { export function HeroCopy() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<HeroKicker>Mission Control</HeroKicker> <HeroKicker>OpenClaw Mission Control</HeroKicker>
<div className="space-y-4"> <div className="space-y-4">
<h1 className="font-heading text-4xl font-semibold leading-tight text-strong sm:text-5xl lg:text-6xl"> <h1 className="font-heading text-4xl font-semibold leading-tight text-strong sm:text-5xl lg:text-6xl">
Enterprise control for Command autonomous work.
<br /> <br />
autonomous execution. <span className="relative inline-flex">
Keep human oversight.
<span
className="absolute inset-x-0 bottom-1 -z-10 h-[0.55em] rounded-md bg-[color:var(--accent-soft)]"
aria-hidden="true"
/>
</span>
</h1> </h1>
<p className="max-w-xl text-base text-muted sm:text-lg"> <p className="max-w-xl text-base text-muted sm:text-lg">
Coordinate boards, agents, and approvals in one command layer. No Track tasks, approvals, and agent health in one calm surface. Get
status meetings. No blind spots. Just durable execution. realtime signals when work changes, without chasing people for status.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,105 +1,269 @@
"use client"; "use client";
import { SignInButton, SignedIn, SignedOut } from "@/auth/clerk"; import Link from "next/link";
import { HeroCopy } from "@/components/molecules/HeroCopy"; import { SignInButton, SignedIn, SignedOut, isClerkEnabled } from "@/auth/clerk";
import { Button } from "@/components/ui/button";
const ArrowIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M6 12L10 8L6 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export function LandingHero() { export function LandingHero() {
const clerkEnabled = isClerkEnabled();
return ( return (
<section className="grid w-full items-center gap-12 lg:grid-cols-[1.1fr_0.9fr]"> <>
<div className="space-y-8 animate-fade-in-up"> <section className="hero">
<HeroCopy /> <div className="hero-content">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="hero-label">OpenClaw Mission Control</div>
<h1>
Command <span className="hero-highlight">autonomous work.</span>
<br />
Keep human oversight.
</h1>
<p>
Track tasks, approvals, and agent health in one unified command
center. Get real-time signals when work changes, without losing the
thread of execution.
</p>
<div className="hero-actions">
<SignedOut> <SignedOut>
{clerkEnabled ? (
<>
<SignInButton <SignInButton
mode="modal" mode="modal"
forceRedirectUrl="/onboarding" forceRedirectUrl="/boards"
signUpForceRedirectUrl="/onboarding" signUpForceRedirectUrl="/boards"
> >
<Button size="lg" className="w-full sm:w-auto"> <button type="button" className="btn-large primary">
Sign in to open mission control Open Boards <ArrowIcon />
</Button> </button>
</SignInButton> </SignInButton>
<SignInButton
mode="modal"
forceRedirectUrl="/boards/new"
signUpForceRedirectUrl="/boards/new"
>
<button type="button" className="btn-large secondary">
Create Board
</button>
</SignInButton>
</>
) : (
<>
<Link href="/boards" className="btn-large primary">
Open Boards <ArrowIcon />
</Link>
<Link href="/boards/new" className="btn-large secondary">
Create Board
</Link>
</>
)}
</SignedOut> </SignedOut>
<SignedIn> <SignedIn>
<div className="text-sm text-muted"> <Link href="/boards" className="btn-large primary">
You&apos;re signed in. Open your boards when you&apos;re ready. Open Boards <ArrowIcon />
</div> </Link>
<Link href="/boards/new" className="btn-large secondary">
Create Board
</Link>
</SignedIn> </SignedIn>
</div> </div>
<div className="flex flex-wrap gap-3 text-xs font-semibold uppercase tracking-[0.28em] text-quiet">
<span className="rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1"> <div className="hero-features">
Enterprise ready {[
</span> "Agent-First Operations",
<span className="rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1"> "Approval Queues",
Agent-first ops "Live Signals",
</span> ].map((label) => (
<span className="rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1"> <div key={label} className="hero-feature">
24/7 visibility <div className="feature-icon"></div>
</span> <span>{label}</span>
</div>
))}
</div> </div>
</div> </div>
<div className="relative animate-fade-in-up"> <div className="command-surface">
<div className="surface-panel rounded-3xl p-6"> <div className="surface-header">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-quiet"> <div className="surface-title">Command Surface</div>
<span>Command surface</span> <div className="live-indicator">
<span className="rounded-full border border-[color:var(--border)] px-2 py-1 text-[10px]"> <div className="live-dot" />
Live LIVE
</span>
</div> </div>
<div className="mt-6 space-y-4">
<div>
<p className="text-lg font-semibold text-strong">
Tasks claimed, tracked, delivered.
</p>
<p className="text-sm text-muted">
See every queue, agent, and handoff without chasing updates.
</p>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="surface-subtitle">
<h3>Ship work without losing the thread.</h3>
<p>Tasks, approvals, and agent status stay synced across the board.</p>
</div>
<div className="metrics-row">
{[ {[
{ label: "Active boards", value: "12" }, { label: "Boards", value: "12" },
{ label: "Agents live", value: "08" }, { label: "Agents", value: "08" },
{ label: "Tasks in flow", value: "46" }, { label: "Tasks", value: "46" },
].map((item) => ( ].map((item) => (
<div <div key={item.label} className="metric">
key={item.label} <div className="metric-value">{item.value}</div>
className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-center" <div className="metric-label">{item.label}</div>
>
<div className="text-xl font-semibold text-strong">
{item.value}
</div> </div>
<div className="text-[11px] uppercase tracking-[0.18em] text-quiet"> ))}
{item.label} </div>
<div className="surface-content">
<div className="content-section">
<h4>Board In Progress</h4>
{[
"Cut release candidate",
"Triage approvals backlog",
"Stabilize agent handoffs",
].map((title) => (
<div key={title} className="status-item">
<div className="status-icon progress"></div>
<div className="status-item-content">
<div className="status-item-title">{title}</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.2em] text-quiet"> <div className="content-section">
<span>Signals</span> <h4>Approvals 3 Pending</h4>
<span>Updated 2m ago</span> {[
</div> { title: "Deploy window confirmed", status: "ready" as const },
<div className="mt-3 space-y-2 text-sm text-muted"> { title: "Copy reviewed", status: "waiting" as const },
<div className="flex items-center justify-between"> { title: "Security sign-off", status: "waiting" as const },
<span>Agent Delta moved task to review</span> ].map((item) => (
<span className="text-quiet">Just now</span> <div key={item.title} className="approval-item">
</div> <div className="approval-title">{item.title}</div>
<div className="flex items-center justify-between"> <div className={`approval-badge ${item.status}`}>
<span>Board Growth Ops hit WIP limit</span> {item.status}
<span className="text-quiet">5m</span>
</div>
<div className="flex items-center justify-between">
<span>Release tasks stabilized</span>
<span className="text-quiet">12m</span>
</div> </div>
</div> </div>
))}
</div> </div>
</div> </div>
<div
style={{
padding: "2rem",
borderTop: "1px solid var(--neutral-200)",
}}
>
<div className="content-section">
<h4>Signals Updated Moments Ago</h4>
{[
{ 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) => (
<div key={signal.text} className="signal-item">
<div className="signal-text">{signal.text}</div>
<div className="signal-time">{signal.time}</div>
</div>
))}
</div>
</div> </div>
</div> </div>
</section> </section>
<section className="features-section" id="capabilities">
<div className="features-grid">
{[
{
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) => (
<div key={feature.title} className="feature-card">
<div className="feature-number">
{String(idx + 1).padStart(2, "0")}
</div>
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</div>
))}
</div>
</section>
<section className="cta-section">
<div className="cta-content">
<h2>Start with one board. Grow into a control room.</h2>
<p>
Onboard a board, name a lead agent, and keep approvals and signals
visible from day one.
</p>
<div className="cta-actions">
<SignedOut>
{clerkEnabled ? (
<>
<SignInButton
mode="modal"
forceRedirectUrl="/boards/new"
signUpForceRedirectUrl="/boards/new"
>
<button type="button" className="btn-large white">
Create Board
</button>
</SignInButton>
<SignInButton
mode="modal"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<button type="button" className="btn-large outline">
View Boards
</button>
</SignInButton>
</>
) : (
<>
<Link href="/boards/new" className="btn-large white">
Create Board
</Link>
<Link href="/boards" className="btn-large outline">
View Boards
</Link>
</>
)}
</SignedOut>
<SignedIn>
<Link href="/boards/new" className="btn-large white">
Create Board
</Link>
<Link href="/boards" className="btn-large outline">
View Boards
</Link>
</SignedIn>
</div>
</div>
</section>
</>
); );
} }

View File

@@ -1,13 +1,25 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { SignOutButton, useUser } from "@/auth/clerk"; 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function UserMenu({ className }: { className?: string }) { export function UserMenu({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const { user } = useUser(); const { user } = useUser();
if (!user) return null; if (!user) return null;
@@ -19,39 +31,59 @@ export function UserMenu({ className }: { className?: string }) {
const displayEmail = user.primaryEmailAddress?.emailAddress ?? ""; const displayEmail = user.primaryEmailAddress?.emailAddress ?? "";
return ( return (
<Popover> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className={cn( className={cn(
"flex h-11 items-center rounded-lg border border-transparent px-1 text-slate-900 transition hover:border-slate-200 hover:bg-slate-50", "group inline-flex h-9 items-center gap-2 rounded-[10px] bg-transparent px-1 py-1 transition",
"hover:bg-white/70",
// Avoid the default browser focus outline (often bright blue) on click.
// Keep a subtle, enterprise-looking focus ring for keyboard navigation.
"focus:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--neutral-300,var(--border-strong))] focus-visible:ring-offset-2 focus-visible:ring-offset-white",
"data-[state=open]:bg-white",
className, className,
)} )}
aria-label="Open user menu" aria-label="Open user menu"
> >
<span className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-lg bg-slate-100 text-sm font-semibold text-slate-900 shadow-sm"> <span
className={cn(
"relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-[10px] text-xs font-semibold text-white shadow-sm",
avatarUrl
? "bg-[color:var(--neutral-200,var(--surface-muted))]"
: "bg-gradient-to-br from-[color:var(--primary-navy,var(--accent))] to-[color:var(--secondary-navy,var(--accent-strong))]",
)}
>
{avatarUrl ? ( {avatarUrl ? (
<Image <Image
src={avatarUrl} src={avatarUrl}
alt="User avatar" alt="User avatar"
width={44} width={36}
height={44} height={36}
className="h-11 w-11 object-cover" className="h-9 w-9 object-cover"
/> />
) : ( ) : (
avatarLabel avatarLabel
)} )}
</span> </span>
<ChevronDown className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))] transition group-data-[state=open]:rotate-180" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
align="end" align="end"
sideOffset={10} sideOffset={12}
className="w-64 rounded-2xl border border-slate-200 bg-white p-0 shadow-lg" className="w-80 overflow-hidden rounded-2xl border border-[color:var(--neutral-200,var(--border))] bg-white/95 p-0 shadow-[0_8px_32px_rgba(10,22,40,0.08)] backdrop-blur"
> >
<div className="border-b border-slate-200 px-4 py-3"> <div className="border-b border-[color:var(--neutral-200,var(--border))] px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg bg-slate-100 text-sm font-semibold text-slate-900"> <span
className={cn(
"flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl text-sm font-semibold text-white",
avatarUrl
? "bg-[color:var(--neutral-200,var(--surface-muted))]"
: "bg-gradient-to-br from-[color:var(--primary-navy,var(--accent))] to-[color:var(--secondary-navy,var(--accent-strong))]",
)}
>
{avatarUrl ? ( {avatarUrl ? (
<Image <Image
src={avatarUrl} src={avatarUrl}
@@ -65,22 +97,67 @@ export function UserMenu({ className }: { className?: string }) {
)} )}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900"> <div className="truncate text-sm font-semibold text-[color:var(--primary-navy,var(--text))]">
{displayName} {displayName}
</div> </div>
{displayEmail ? ( {displayEmail ? (
<div className="truncate text-xs text-slate-500">{displayEmail}</div> <div className="truncate text-xs text-[color:var(--neutral-700,var(--text-muted))]">
{displayEmail}
</div>
) : null} ) : null}
</div> </div>
</div> </div>
</div> </div>
<div className="p-2"> <div className="p-2">
<div className="grid grid-cols-2 gap-2">
<Link
href="/boards"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-[color:var(--neutral-300,var(--border-strong))] bg-white px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:border-[color:var(--primary-navy,var(--accent-strong))] hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
onClick={() => setOpen(false)}
>
<Trello className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
Open boards
</Link>
<Link
href="/boards/new"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[color:var(--primary-navy,var(--accent))] px-3 py-2 text-sm font-semibold text-white shadow-[0_2px_8px_rgba(10,22,40,0.15)] transition hover:bg-[color:var(--secondary-navy,var(--accent-strong))] hover:translate-y-[-1px] hover:shadow-[0_4px_12px_rgba(10,22,40,0.20)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
onClick={() => setOpen(false)}
>
<Plus className="h-4 w-4 opacity-90" />
Create board
</Link>
</div>
<div className="my-2 h-px bg-[color:var(--neutral-200,var(--border))]" />
{(
[
{ 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) => (
<Link
key={item.href}
href={item.href}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
onClick={() => setOpen(false)}
>
<item.icon className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
{item.label}
</Link>
))}
<div className="my-2 h-px bg-[color:var(--neutral-200,var(--border))]" />
<SignOutButton> <SignOutButton>
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-slate-900 transition hover:bg-slate-100" className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
onClick={() => setOpen(false)}
> >
<LogOut className="h-4 w-4 text-slate-500" /> <LogOut className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
Sign out Sign out
</button> </button>
</SignOutButton> </SignOutButton>

View File

@@ -1,39 +1,159 @@
"use client"; "use client";
import Link from "next/link";
import type { ReactNode } from "react"; 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"; import { UserMenu } from "@/components/organisms/UserMenu";
export function LandingShell({ children }: { children: ReactNode }) { export function LandingShell({ children }: { children: ReactNode }) {
return ( const clerkEnabled = isClerkEnabled();
<div className="landing-page bg-app text-strong">
<section className="relative overflow-hidden px-4 pb-20 pt-16 sm:px-6 lg:px-8"> return (
<div <div className="landing-enterprise">
className="absolute inset-0 bg-landing-grid opacity-[0.18] pointer-events-none" <nav className="landing-nav" aria-label="Primary navigation">
aria-hidden="true" <div className="nav-container">
/> <Link href="/" className="logo-section" aria-label="OpenClaw home">
<div <div className="logo-icon" aria-hidden="true">
className="absolute -top-40 right-0 h-72 w-72 rounded-full bg-[color:var(--accent-soft)] blur-3xl pointer-events-none" OC
aria-hidden="true" </div>
/> <div className="logo-text">
<div <div className="logo-name">OpenClaw</div>
className="absolute -bottom-32 left-0 h-72 w-72 rounded-full bg-[color:var(--surface-strong)] blur-3xl pointer-events-none" <div className="logo-tagline">Mission Control</div>
aria-hidden="true" </div>
/> </Link>
<div className="nav-links">
<Link href="#capabilities">Capabilities</Link>
<Link href="/boards">Boards</Link>
<Link href="/activity">Activity</Link>
<Link href="/gateways">Gateways</Link>
</div>
<div className="nav-cta">
<SignedOut>
{clerkEnabled ? (
<>
<SignInButton
mode="modal"
forceRedirectUrl="/onboarding"
signUpForceRedirectUrl="/onboarding"
>
<button type="button" className="btn-secondary">
Sign In
</button>
</SignInButton>
<SignInButton
mode="modal"
forceRedirectUrl="/onboarding"
signUpForceRedirectUrl="/onboarding"
>
<button type="button" className="btn-primary">
Start Free Trial
</button>
</SignInButton>
</>
) : (
<>
<Link href="/boards" className="btn-secondary">
Boards
</Link>
<Link href="/onboarding" className="btn-primary">
Get started
</Link>
</>
)}
</SignedOut>
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-12">
<header className="flex items-center justify-between gap-4">
<BrandMark />
<SignedIn> <SignedIn>
<Link href="/boards/new" className="btn-secondary">
Create Board
</Link>
<Link href="/boards" className="btn-primary">
Open Boards
</Link>
<UserMenu /> <UserMenu />
</SignedIn> </SignedIn>
</header>
<main>{children}</main>
</div> </div>
</section> </div>
</nav>
<main>{children}</main>
<footer className="landing-footer">
<div className="footer-content">
<div className="footer-brand">
<h3>OpenClaw</h3>
<p>A calm command center for boards, agents, and approvals.</p>
<div className="footer-tagline">Realtime Execution Visibility</div>
</div>
<div className="footer-column">
<h4>Product</h4>
<div className="footer-links">
<Link href="#capabilities">Capabilities</Link>
<Link href="/boards">Boards</Link>
<Link href="/activity">Activity</Link>
<Link href="/dashboard">Dashboard</Link>
</div>
</div>
<div className="footer-column">
<h4>Platform</h4>
<div className="footer-links">
<Link href="/gateways">Gateways</Link>
<Link href="/agents">Agents</Link>
<Link href="/dashboard">Dashboard</Link>
</div>
</div>
<div className="footer-column">
<h4>Access</h4>
<div className="footer-links">
<SignedOut>
{clerkEnabled ? (
<>
<SignInButton
mode="modal"
forceRedirectUrl="/onboarding"
signUpForceRedirectUrl="/onboarding"
>
<button type="button">Sign In</button>
</SignInButton>
<SignInButton
mode="modal"
forceRedirectUrl="/onboarding"
signUpForceRedirectUrl="/onboarding"
>
<button type="button">Create Account</button>
</SignInButton>
</>
) : (
<Link href="/boards">Boards</Link>
)}
<Link href="/onboarding">Onboarding</Link>
</SignedOut>
<SignedIn>
<Link href="/boards">Open Boards</Link>
<Link href="/boards/new">Create Board</Link>
<Link href="/dashboard">Dashboard</Link>
</SignedIn>
</div>
</div>
</div>
<div className="footer-bottom">
<div className="footer-copyright">
© {new Date().getFullYear()} OpenClaw. All rights reserved.
</div>
<div className="footer-bottom-links">
<Link href="#capabilities">Capabilities</Link>
<Link href="/boards">Boards</Link>
<Link href="/activity">Activity</Link>
</div>
</div>
</footer>
</div> </div>
); );
} }

View File

@@ -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<boolean>(() => 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;
}

View File

@@ -7,6 +7,7 @@ module.exports = {
fontFamily: { fontFamily: {
heading: ["var(--font-heading)", "sans-serif"], heading: ["var(--font-heading)", "sans-serif"],
body: ["var(--font-body)", "sans-serif"], body: ["var(--font-body)", "sans-serif"],
display: ["var(--font-display)", "serif"],
}, },
}, },
}, },