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
# --- database ---
POSTGRES_DB=openclaw_agency
POSTGRES_DB=mission_control
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5432

View File

@@ -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=

View File

@@ -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] = {}

View File

@@ -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}

View File

@@ -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";
@@ -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) => {

View File

@@ -10,8 +10,12 @@ import {
Activity,
ArrowUpRight,
MessageSquare,
Pause,
Plus,
Pencil,
Play,
Settings,
ShieldCheck,
X,
} from "lucide-react";
@@ -72,6 +76,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 +303,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<Board | null>(null);
@@ -340,6 +346,15 @@ export default function BoardDetailPage() {
const [chatError, setChatError] = useState<string | null>(null);
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
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 [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board");
@@ -579,8 +594,22 @@ 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;
if (!isChatOpen) return;
let isCancelled = false;
const abortController = new AbortController();
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
@@ -685,9 +714,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 +847,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout);
}
};
}, [board, boardId, isSignedIn]);
}, [board, boardId, isPageActive, isSignedIn]);
useEffect(() => {
if (!selectedTask) {
@@ -840,6 +870,7 @@ export default function BoardDetailPage() {
}, [selectedTask]);
useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return;
let isCancelled = false;
const abortController = new AbortController();
@@ -1003,9 +1034,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 +1141,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout);
}
};
}, [board, boardId, isSignedIn]);
}, [board, boardId, isPageActive, isSignedIn]);
const resetForm = () => {
setTitle("");
@@ -1156,13 +1188,16 @@ export default function BoardDetailPage() {
}
};
const handleSendChat = useCallback(
async (content: string): Promise<boolean> => {
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,
@@ -1188,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<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 map = new Map<string, string>();
agents
@@ -1862,21 +1940,48 @@ export default function BoardDetailPage() {
List
</button>
</div>
<Button onClick={() => setIsDialogOpen(true)}>
New task
<Button
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
variant="outline"
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 ? (
<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}
</span>
) : null}
</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
variant="outline"
onClick={openBoardChat}
@@ -2792,6 +2897,66 @@ export default function BoardDetailPage() {
</DialogContent>
</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 */}
</DashboardShell>
);

View File

@@ -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;
}
}

View File

@@ -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 (
<html lang="en">
<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>
<QueryProvider>{children}</QueryProvider>

View File

@@ -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<BoardOnboardingRead | null>(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) => {

View File

@@ -3,16 +3,22 @@ import { HeroKicker } from "@/components/atoms/HeroKicker";
export function HeroCopy() {
return (
<div className="space-y-6">
<HeroKicker>Mission Control</HeroKicker>
<HeroKicker>OpenClaw Mission Control</HeroKicker>
<div className="space-y-4">
<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 />
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>
<p className="max-w-xl text-base text-muted sm:text-lg">
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.
</p>
</div>
</div>

View File

@@ -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 = () => (
<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() {
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">
<HeroCopy />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<SignedOut>
<SignInButton
mode="modal"
forceRedirectUrl="/onboarding"
signUpForceRedirectUrl="/onboarding"
>
<Button size="lg" className="w-full sm:w-auto">
Sign in to open mission control
</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<div className="text-sm text-muted">
You&apos;re signed in. Open your boards when you&apos;re ready.
</div>
</SignedIn>
</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">
Enterprise ready
</span>
<span className="rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1">
Agent-first ops
</span>
<span className="rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1">
24/7 visibility
</span>
</div>
</div>
const clerkEnabled = isClerkEnabled();
<div className="relative animate-fade-in-up">
<div className="surface-panel rounded-3xl p-6">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
<span>Command surface</span>
<span className="rounded-full border border-[color:var(--border)] px-2 py-1 text-[10px]">
Live
</span>
return (
<>
<section className="hero">
<div className="hero-content">
<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>
{clerkEnabled ? (
<>
<SignInButton
mode="modal"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<button type="button" className="btn-large primary">
Open Boards <ArrowIcon />
</button>
</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>
<SignedIn>
<Link href="/boards" className="btn-large primary">
Open Boards <ArrowIcon />
</Link>
<Link href="/boards/new" className="btn-large secondary">
Create Board
</Link>
</SignedIn>
</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 className="hero-features">
{[
"Agent-First Operations",
"Approval Queues",
"Live Signals",
].map((label) => (
<div key={label} className="hero-feature">
<div className="feature-icon"></div>
<span>{label}</span>
</div>
))}
</div>
</div>
<div className="command-surface">
<div className="surface-header">
<div className="surface-title">Command Surface</div>
<div className="live-indicator">
<div className="live-dot" />
LIVE
</div>
<div className="grid grid-cols-3 gap-3">
</div>
<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: "Boards", value: "12" },
{ label: "Agents", value: "08" },
{ label: "Tasks", value: "46" },
].map((item) => (
<div key={item.label} className="metric">
<div className="metric-value">{item.value}</div>
<div className="metric-label">{item.label}</div>
</div>
))}
</div>
<div className="surface-content">
<div className="content-section">
<h4>Board In Progress</h4>
{[
{ label: "Active boards", value: "12" },
{ label: "Agents live", value: "08" },
{ label: "Tasks in flow", value: "46" },
].map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-center"
>
<div className="text-xl font-semibold text-strong">
{item.value}
</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-quiet">
{item.label}
"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 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">
<span>Signals</span>
<span>Updated 2m ago</span>
</div>
<div className="mt-3 space-y-2 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Agent Delta moved task to review</span>
<span className="text-quiet">Just now</span>
<div className="content-section">
<h4>Approvals 3 Pending</h4>
{[
{ 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) => (
<div key={item.title} className="approval-item">
<div className="approval-title">{item.title}</div>
<div className={`approval-badge ${item.status}`}>
{item.status}
</div>
</div>
<div className="flex items-center justify-between">
<span>Board Growth Ops hit WIP limit</span>
<span className="text-quiet">5m</span>
))}
</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 className="flex items-center justify-between">
<span>Release tasks stabilized</span>
<span className="text-quiet">12m</span>
</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";
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 (
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
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,
)}
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 ? (
<Image
src={avatarUrl}
alt="User avatar"
width={44}
height={44}
className="h-11 w-11 object-cover"
width={36}
height={36}
className="h-9 w-9 object-cover"
/>
) : (
avatarLabel
)}
</span>
<ChevronDown className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))] transition group-data-[state=open]:rotate-180" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={10}
className="w-64 rounded-2xl border border-slate-200 bg-white p-0 shadow-lg"
sideOffset={12}
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">
<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 ? (
<Image
src={avatarUrl}
@@ -65,22 +97,67 @@ export function UserMenu({ className }: { className?: string }) {
)}
</span>
<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}
</div>
{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}
</div>
</div>
</div>
<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>
<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
</button>
</SignOutButton>

View File

@@ -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 (
<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">
<div
className="absolute inset-0 bg-landing-grid opacity-[0.18] pointer-events-none"
aria-hidden="true"
/>
<div
className="absolute -top-40 right-0 h-72 w-72 rounded-full bg-[color:var(--accent-soft)] blur-3xl pointer-events-none"
aria-hidden="true"
/>
<div
className="absolute -bottom-32 left-0 h-72 w-72 rounded-full bg-[color:var(--surface-strong)] blur-3xl pointer-events-none"
aria-hidden="true"
/>
const clerkEnabled = isClerkEnabled();
return (
<div className="landing-enterprise">
<nav className="landing-nav" aria-label="Primary navigation">
<div className="nav-container">
<Link href="/" className="logo-section" aria-label="OpenClaw home">
<div className="logo-icon" aria-hidden="true">
OC
</div>
<div className="logo-text">
<div className="logo-name">OpenClaw</div>
<div className="logo-tagline">Mission Control</div>
</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>
<Link href="/boards/new" className="btn-secondary">
Create Board
</Link>
<Link href="/boards" className="btn-primary">
Open Boards
</Link>
<UserMenu />
</SignedIn>
</header>
<main>{children}</main>
</div>
</div>
</section>
</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>
);
}

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: {
heading: ["var(--font-heading)", "sans-serif"],
body: ["var(--font-body)", "sans-serif"],
display: ["var(--font-display)", "serif"],
},
},
},