Merge master into fix/next-allowed-dev-origins

This commit is contained in:
Arjun (OpenClaw)
2026-02-07 19:17:53 +00:00
24 changed files with 739 additions and 91 deletions

View File

@@ -4,7 +4,7 @@ This package is the **Next.js** web UI for OpenClaw Mission Control.
- Talks to the Mission Control **backend** over HTTP (typically `http://localhost:8000`).
- Uses **React Query** for data fetching.
- Can optionally enable **Clerk** authentication (disabled by default unless you provide a *real* Clerk publishable key).
- Can optionally enable **Clerk** authentication (disabled by default unless you provide a _real_ Clerk publishable key).
## Prerequisites
@@ -73,7 +73,7 @@ Implementation detail: we gate on a conservative regex (`pk_test_...` / `pk_live
- `NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL`
**Important:** `frontend/.env.example` contains placeholder values like `YOUR_PUBLISHABLE_KEY`.
Those placeholders are *not* valid keys and are intentionally treated as “Clerk disabled”.
Those placeholders are _not_ valid keys and are intentionally treated as “Clerk disabled”.
## How the frontend talks to the backend
@@ -159,4 +159,9 @@ Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` pub
`next.config.ts` sets `allowedDevOrigins` for dev proxy safety.
If you see repeated proxy errors (often `ECONNRESET`), make sure your dev server hostname and browser URL match (e.g. `localhost` vs `127.0.0.1`), and that your origin is included in `allowedDevOrigins`.
If you see repeated proxy errors (often `ECONNRESET`), make sure your dev server hostname and browser URL match (e.g. `localhost` vs `127.0.0.1`), and that your origin is included in `allowedDevOrigins`.
Notes:
- Local dev should work via `http://localhost:3000` and `http://127.0.0.1:3000`.
- LAN dev should work via the configured LAN IP (e.g. `http://192.168.1.101:3000`) **only** if you bind the dev server to all interfaces (`npm run dev:lan`).
- If you bind Next to `127.0.0.1` only, remote LAN clients wont connect.

View File

@@ -26,15 +26,23 @@ vi.mock("next/link", () => {
// wrappers still render <SignedOut/> from @clerk/nextjs (which crashes in real builds).
vi.mock("@clerk/nextjs", () => {
return {
ClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ClerkProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SignedIn: () => {
throw new Error("@clerk/nextjs SignedIn rendered (unexpected in secretless mode)");
throw new Error(
"@clerk/nextjs SignedIn rendered (unexpected in secretless mode)",
);
},
SignedOut: () => {
throw new Error("@clerk/nextjs SignedOut rendered without ClerkProvider");
},
SignInButton: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SignOutButton: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SignInButton: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SignOutButton: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
useAuth: () => ({ isLoaded: true, isSignedIn: false }),
useUser: () => ({ isLoaded: true, isSignedIn: false, user: null }),
};

View File

@@ -68,16 +68,27 @@ const getBoardOptions = (boards: BoardRead[]): SearchableSelectOption[] =>
label: board.name,
}));
const normalizeIdentityProfile = (
profile: IdentityProfile,
): IdentityProfile | null => {
const normalized: IdentityProfile = {
role: profile.role.trim(),
communication_style: profile.communication_style.trim(),
emoji: profile.emoji.trim(),
const mergeIdentityProfile = (
existing: unknown,
patch: IdentityProfile,
): Record<string, unknown> | null => {
const resolved: Record<string, unknown> =
existing && typeof existing === "object"
? { ...(existing as Record<string, unknown>) }
: {};
const updates: Record<string, string> = {
role: patch.role.trim(),
communication_style: patch.communication_style.trim(),
emoji: patch.emoji.trim(),
};
const hasValue = Object.values(normalized).some((value) => value.length > 0);
return hasValue ? normalized : null;
for (const [key, value] of Object.entries(updates)) {
if (value) {
resolved[key] = value;
} else {
delete resolved[key];
}
}
return Object.keys(resolved).length > 0 ? resolved : null;
};
const withIdentityDefaults = (
@@ -241,7 +252,8 @@ export default function EditAgentPage() {
every: resolvedHeartbeatEvery.trim() || "10m",
target: resolvedHeartbeatTarget,
} as unknown as Record<string, unknown>,
identity_profile: normalizeIdentityProfile(
identity_profile: mergeIdentityProfile(
loadedAgent.identity_profile,
resolvedIdentityProfile,
) as unknown as Record<string, unknown> | null,
soul_template: resolvedSoulTemplate.trim() || null,

View File

@@ -135,7 +135,11 @@ const SSE_RECONNECT_BACKOFF = {
type HeartbeatUnit = "s" | "m" | "h" | "d";
const HEARTBEAT_PRESETS: Array<{ label: string; amount: number; unit: HeartbeatUnit }> = [
const HEARTBEAT_PRESETS: Array<{
label: string;
amount: number;
unit: HeartbeatUnit;
}> = [
{ label: "30s", amount: 30, unit: "s" },
{ label: "1m", amount: 1, unit: "m" },
{ label: "2m", amount: 2, unit: "m" },
@@ -781,22 +785,22 @@ export default function BoardGroupDetailPage() {
{HEARTBEAT_PRESETS.map((preset) => {
const value = `${preset.amount}${preset.unit}`;
return (
<button
key={value}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
heartbeatEvery === value
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
)}
onClick={() => {
setHeartbeatAmount(String(preset.amount));
setHeartbeatUnit(preset.unit);
}}
>
{preset.label}
</button>
<button
key={value}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
heartbeatEvery === value
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
)}
onClick={() => {
setHeartbeatAmount(String(preset.amount));
setHeartbeatUnit(preset.unit);
}}
>
{preset.label}
</button>
);
})}
</div>

View File

@@ -21,7 +21,11 @@ import {
useDeleteBoardApiV1BoardsBoardIdDelete,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { BoardRead } from "@/api/generated/model";
import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -46,6 +50,9 @@ const formatTimestamp = (value?: string | null) => {
});
};
const compactId = (value: string) =>
value.length > 8 ? `${value.slice(0, 8)}` : value;
export default function BoardsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
@@ -63,6 +70,20 @@ export default function BoardsPage() {
},
});
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
listBoardGroupsApiV1BoardGroupsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
},
},
);
const boards = useMemo(
() =>
boardsQuery.data?.status === 200
@@ -71,6 +92,19 @@ export default function BoardsPage() {
[boardsQuery.data],
);
const groups = useMemo<BoardGroupRead[]>(() => {
if (groupsQuery.data?.status !== 200) return [];
return groupsQuery.data.data.items ?? [];
}, [groupsQuery.data]);
const groupById = useMemo(() => {
const map = new Map<string, BoardGroupRead>();
for (const group of groups) {
map.set(group.id, group);
}
return map;
}, [groups]);
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
ApiError,
{ previous?: listBoardsApiV1BoardsGetResponse }
@@ -136,6 +170,28 @@ export default function BoardsPage() {
</Link>
),
},
{
id: "group",
header: "Group",
cell: ({ row }) => {
const groupId = row.original.board_group_id;
if (!groupId) {
return <span className="text-sm text-slate-400"></span>;
}
const group = groupById.get(groupId);
const label = group?.name ?? compactId(groupId);
const title = group?.name ?? groupId;
return (
<Link
href={`/board-groups/${groupId}`}
className="text-sm font-medium text-slate-700 hover:text-blue-600"
title={title}
>
{label}
</Link>
);
},
},
{
accessorKey: "updated_at",
header: "Updated",
@@ -167,7 +223,7 @@ export default function BoardsPage() {
),
},
],
[],
[groupById],
);
// eslint-disable-next-line react-hooks/incompatible-library

View File

@@ -3,7 +3,9 @@
// IMPORTANT: keep this file dependency-free (no `"use client"`, no React, no Clerk imports)
// so it can be used from both client and server/edge entrypoints.
export function isLikelyValidClerkPublishableKey(key: string | undefined): key is string {
export function isLikelyValidClerkPublishableKey(
key: string | undefined,
): key is string {
if (!key) return false;
// Clerk publishable keys look like: pk_test_... or pk_live_...

View File

@@ -447,36 +447,6 @@ export function BoardOnboardingChat({
<span className="font-medium text-slate-900">Emoji:</span>{" "}
{draft.lead_agent.identity_profile?.emoji || "—"}
</p>
<p className="text-slate-700">
<span className="font-medium text-slate-900">Autonomy:</span>{" "}
{draft.lead_agent.autonomy_level || "—"}
</p>
<p className="text-slate-700">
<span className="font-medium text-slate-900">Verbosity:</span>{" "}
{draft.lead_agent.verbosity || "—"}
</p>
<p className="text-slate-700">
<span className="font-medium text-slate-900">
Output format:
</span>{" "}
{draft.lead_agent.output_format || "—"}
</p>
<p className="text-slate-700">
<span className="font-medium text-slate-900">
Update cadence:
</span>{" "}
{draft.lead_agent.update_cadence || "—"}
</p>
{draft.lead_agent.custom_instructions ? (
<>
<p className="mt-3 font-semibold text-slate-900">
Custom instructions
</p>
<pre className="mt-1 whitespace-pre-wrap text-xs text-slate-600">
{draft.lead_agent.custom_instructions}
</pre>
</>
) : null}
</>
) : null}
</div>

View File

@@ -4,7 +4,9 @@ import { clerkMiddleware } from "@clerk/nextjs/server";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
const isClerkEnabled = () =>
isLikelyValidClerkPublishableKey(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
isLikelyValidClerkPublishableKey(
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
);
export default isClerkEnabled() ? clerkMiddleware() : () => NextResponse.next();