feat(agents): Add identity and soul template fields to board creation

This commit is contained in:
Abhimanyu Saharan
2026-02-04 20:21:33 +05:30
parent 1c972edb46
commit c3357f92d9
117 changed files with 7899 additions and 1339 deletions

View File

@@ -9,6 +9,7 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
import {
Select,
SelectContent,
@@ -17,9 +18,7 @@ import {
SelectValue,
} from "@/components/ui/select";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
type Agent = {
id: string;

View File

@@ -18,10 +18,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
type Agent = {
id: string;

View File

@@ -9,6 +9,7 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
import {
Select,
SelectContent,
@@ -17,9 +18,7 @@ import {
SelectValue,
} from "@/components/ui/select";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
type Agent = {
id: string;

View File

@@ -33,10 +33,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
type Agent = {
id: string;

View File

@@ -9,10 +9,10 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
type Board = {
id: string;
@@ -21,6 +21,8 @@ type Board = {
gateway_url?: string | null;
gateway_main_session_key?: string | null;
gateway_workspace_root?: string | null;
identity_template?: string | null;
soul_template?: string | null;
};
const slugify = (value: string) =>
@@ -44,6 +46,8 @@ export default function EditBoardPage() {
const [gatewayToken, setGatewayToken] = useState("");
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState("");
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState("");
const [identityTemplate, setIdentityTemplate] = useState("");
const [soulTemplate, setSoulTemplate] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -66,6 +70,8 @@ export default function EditBoardPage() {
setGatewayUrl(data.gateway_url ?? "");
setGatewayMainSessionKey(data.gateway_main_session_key ?? "");
setGatewayWorkspaceRoot(data.gateway_workspace_root ?? "");
setIdentityTemplate(data.identity_template ?? "");
setSoulTemplate(data.soul_template ?? "");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -96,6 +102,8 @@ export default function EditBoardPage() {
gateway_url: gatewayUrl.trim() || null,
gateway_main_session_key: gatewayMainSessionKey.trim() || null,
gateway_workspace_root: gatewayWorkspaceRoot.trim() || null,
identity_template: identityTemplate.trim() || null,
soul_template: soulTemplate.trim() || null,
};
if (gatewayToken.trim()) {
payload.gateway_token = gatewayToken.trim();
@@ -210,6 +218,28 @@ export default function EditBoardPage() {
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Identity template (optional)
</label>
<Textarea
value={identityTemplate}
onChange={(event) => setIdentityTemplate(event.target.value)}
placeholder="Override IDENTITY.md for agents in this board."
className="min-h-[140px]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Soul template (optional)
</label>
<Textarea
value={soulTemplate}
onChange={(event) => setSoulTemplate(event.target.value)}
placeholder="Override SOUL.md for agents in this board."
className="min-h-[160px]"
/>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}

View File

@@ -26,6 +26,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
type Board = {
id: string;
@@ -57,9 +58,7 @@ type TaskComment = {
created_at: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
const priorities = [
{ value: "low", label: "Low" },

View File

@@ -9,6 +9,8 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
type Board = {
id: string;
@@ -18,11 +20,11 @@ type Board = {
gateway_token?: string | null;
gateway_main_session_key?: string | null;
gateway_workspace_root?: string | null;
identity_template?: string | null;
soul_template?: string | null;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
const slugify = (value: string) =>
value
@@ -39,6 +41,8 @@ export default function NewBoardPage() {
const [gatewayToken, setGatewayToken] = useState("");
const [gatewayMainSessionKey, setGatewayMainSessionKey] = useState("");
const [gatewayWorkspaceRoot, setGatewayWorkspaceRoot] = useState("");
const [identityTemplate, setIdentityTemplate] = useState("");
const [soulTemplate, setSoulTemplate] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -63,6 +67,12 @@ export default function NewBoardPage() {
if (gatewayWorkspaceRoot.trim()) {
payload.gateway_workspace_root = gatewayWorkspaceRoot.trim();
}
if (identityTemplate.trim()) {
payload.identity_template = identityTemplate.trim();
}
if (soulTemplate.trim()) {
payload.soul_template = soulTemplate.trim();
}
const response = await fetch(`${apiBase}/api/v1/boards`, {
method: "POST",
headers: {
@@ -170,6 +180,28 @@ export default function NewBoardPage() {
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Identity template (optional)
</label>
<Textarea
value={identityTemplate}
onChange={(event) => setIdentityTemplate(event.target.value)}
placeholder="Override IDENTITY.md for agents in this board."
className="min-h-[140px]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Soul template (optional)
</label>
<Textarea
value={soulTemplate}
onChange={(event) => setSoulTemplate(event.target.value)}
placeholder="Override SOUL.md for agents in this board."
className="min-h-[160px]"
/>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}

View File

@@ -15,6 +15,7 @@ import {
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { getApiBaseUrl } from "@/lib/api-base";
import {
Dialog,
DialogContent,
@@ -30,9 +31,7 @@ type Board = {
slug: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const apiBase = getApiBaseUrl();
export default function BoardsPage() {
const { getToken, isSignedIn } = useAuth();

View File

@@ -20,8 +20,8 @@ export default function DashboardPage() {
</p>
<SignInButton
mode="modal"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
forceRedirectUrl="/onboarding"
signUpForceRedirectUrl="/onboarding"
>
<Button>Sign in</Button>
</SignInButton>

View File

@@ -0,0 +1,275 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@clerk/nextjs";
import { Globe, Info, RotateCcw, Save, User } from "lucide-react";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import SearchableSelect from "@/components/ui/searchable-select";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
type UserProfile = {
id: string;
name?: string | null;
preferred_name?: string | null;
pronouns?: string | null;
timezone?: string | null;
notes?: string | null;
context?: string | null;
};
const isCompleteProfile = (profile: UserProfile | null) => {
if (!profile) return false;
const resolvedName = profile.preferred_name?.trim() || profile.name?.trim();
return Boolean(resolvedName) && Boolean(profile.timezone?.trim());
};
export default function OnboardingPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const { user } = useUser();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [name, setName] = useState("");
const [timezone, setTimezone] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requiredMissing = useMemo(
() => [name, timezone].some((value) => !value.trim()),
[name, timezone]
);
const timezones = useMemo(() => {
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
return (Intl as typeof Intl & { supportedValuesOf: (key: string) => string[] })
.supportedValuesOf("timeZone")
.sort();
}
return [
"America/Los_Angeles",
"America/Denver",
"America/Chicago",
"America/New_York",
"America/Sao_Paulo",
"Europe/London",
"Europe/Berlin",
"Europe/Paris",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Sydney",
];
}, []);
const timezoneOptions = useMemo(
() => timezones.map((tz) => ({ value: tz, label: tz })),
[timezones],
);
const loadProfile = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/users/me`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load profile.");
}
const data = (await response.json()) as UserProfile;
setProfile(data);
const fallbackName =
user?.fullName ?? user?.firstName ?? user?.username ?? "";
setName(data.preferred_name ?? data.name ?? fallbackName);
setTimezone(data.timezone ?? "");
if (isCompleteProfile(data)) {
router.replace("/boards");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!name.trim() && user) {
const fallbackName =
user.fullName ?? user.firstName ?? user.username ?? "";
if (fallbackName) {
setName(fallbackName);
}
}
}, [user, name]);
useEffect(() => {
loadProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
if (requiredMissing) {
setError("Please complete the required fields.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const normalizedName = name.trim();
const payload = {
name: normalizedName,
preferred_name: normalizedName,
timezone: timezone.trim(),
};
const response = await fetch(`${apiBase}/api/v1/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Unable to update profile.");
}
router.replace("/boards");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="lg:col-span-2 flex min-h-[70vh] items-center justify-center">
<div className="w-full max-w-2xl rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-6 py-5">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Mission Control profile
</h1>
<p className="mt-1 text-sm text-slate-600">
Sign in to configure your profile and timezone.
</p>
</div>
<div className="px-6 py-6">
<SignInButton
mode="modal"
forceRedirectUrl="/onboarding"
signUpForceRedirectUrl="/onboarding"
>
<Button size="lg">Sign in</Button>
</SignInButton>
</div>
</div>
</div>
</SignedOut>
<SignedIn>
<div className="lg:col-span-2 flex min-h-[70vh] items-center justify-center">
<section className="w-full max-w-2xl rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-6 py-5">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Mission Control profile
</h1>
<p className="mt-1 text-sm text-slate-600">
Configure your mission control settings and preferences.
</p>
</div>
<div className="px-6 py-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700 flex items-center gap-2">
<User className="h-4 w-4 text-slate-500" />
Name
<span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Enter your name"
disabled={isLoading}
className="border-slate-300 text-slate-900 focus-visible:ring-blue-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700 flex items-center gap-2">
<Globe className="h-4 w-4 text-slate-500" />
Timezone
<span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select timezone"
value={timezone}
onValueChange={setTimezone}
options={timezoneOptions}
placeholder="Select timezone"
searchPlaceholder="Search timezones..."
emptyMessage="No matching timezones."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800 flex items-start gap-3">
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
<p>
<strong>Note:</strong> Your timezone is used to display all
timestamps and schedule mission-critical events accurately.
</p>
</div>
{error ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600">
{error}
</div>
) : null}
<div className="flex flex-wrap gap-3 pt-2">
<Button
type="submit"
className="flex-1 bg-blue-600 text-white hover:bg-blue-700 py-2.5"
disabled={isLoading || requiredMissing}
>
<Save className="h-4 w-4" />
{isLoading ? "Saving…" : "Save Profile"}
</Button>
<button
type="button"
onClick={() => {
setName("");
setTimezone("");
setError(null);
}}
className="flex-1 rounded-md border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<span className="inline-flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
Reset
</span>
</button>
</div>
</form>
</div>
</section>
</div>
</SignedIn>
</DashboardShell>
);
}