feat: add is_board_lead property to agent and board types

This commit is contained in:
Abhimanyu Saharan
2026-02-05 19:06:32 +05:30
parent 9e8ff0dadf
commit 4cc6c42440
21 changed files with 767 additions and 360 deletions

View File

@@ -201,7 +201,9 @@ export default function EditAgentPage() {
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, {
const response = await fetch(
`${apiBase}/api/v1/agents/${agentId}?force=true`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
@@ -217,7 +219,8 @@ export default function EditAgentPage() {
identity_profile: normalizeIdentityProfile(identityProfile),
soul_template: soulTemplate.trim() || null,
}),
});
}
);
if (!response.ok) {
throw new Error("Unable to update agent.");
}

View File

@@ -31,6 +31,7 @@ type Agent = {
created_at: string;
updated_at: string;
board_id?: string | null;
is_board_lead?: boolean;
};
type Board = {
@@ -106,6 +107,7 @@ export default function AgentDetailPage() {
return boards.find((board) => board.id === agent.board_id) ?? null;
}, [boards, agent?.board_id]);
const loadAgent = async () => {
if (!isSignedIn || !agentId) return;
setIsLoading(true);

View File

@@ -38,6 +38,7 @@ type Agent = {
created_at: string;
updated_at: string;
board_id?: string | null;
is_board_lead?: boolean;
};
type Board = {
@@ -138,6 +139,7 @@ export default function AgentsPage() {
const sortedAgents = useMemo(() => [...agents], [agents]);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget);

View File

@@ -5,9 +5,13 @@ import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -74,6 +78,7 @@ export default function EditBoardPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
const isFormReady = Boolean(name.trim() && gatewayId);
@@ -123,6 +128,17 @@ export default function EditBoardPage() {
}
};
const handleOnboardingConfirmed = (updated: Board) => {
setBoard(updated);
setBoardType(updated.board_type ?? "goal");
setObjective(updated.objective ?? "");
setSuccessMetrics(
updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : ""
);
setTargetDate(toDateInput(updated.target_date));
setIsOnboardingOpen(false);
};
useEffect(() => {
loadBoard();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -199,7 +215,8 @@ export default function EditBoardPage() {
};
return (
<DashboardShell>
<>
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
@@ -229,126 +246,152 @@ export default function EditBoardPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !board}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
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="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={boardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={targetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={objective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<BoardGoalPanel
board={board}
onStartOnboarding={() => setIsOnboardingOpen(true)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={successMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !board || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !board}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
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="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={boardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={targetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={objective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={successMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !board || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</form>
<div className="space-y-6">
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
</div>
</div>
</div>
</main>
</SignedIn>
</DashboardShell>
</DashboardShell>
<Dialog open={isOnboardingOpen} onOpenChange={setIsOnboardingOpen}>
<DialogContent aria-label="Board onboarding">
{boardId ? (
<BoardOnboardingChat
boardId={boardId}
onConfirmed={handleOnboardingConfirmed}
/>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
Unable to start onboarding.
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -7,9 +7,6 @@ import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -62,6 +59,10 @@ type Agent = {
name: string;
status: string;
board_id?: string | null;
is_board_lead?: boolean;
identity_profile?: {
emoji?: string | null;
} | null;
};
type TaskComment = {
@@ -80,6 +81,19 @@ const priorities = [
{ value: "high", label: "High" },
];
const EMOJI_GLYPHS: Record<string, string> = {
":gear:": "⚙️",
":sparkles:": "✨",
":rocket:": "🚀",
":megaphone:": "📣",
":chart_with_upwards_trend:": "📈",
":bulb:": "💡",
":wrench:": "🔧",
":shield:": "🛡️",
":memo:": "📝",
":brain:": "🧠",
};
export default function BoardDetailPage() {
const router = useRouter();
const params = useParams();
@@ -100,8 +114,6 @@ export default function BoardDetailPage() {
const tasksRef = useRef<Task[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
const [hasPromptedOnboarding, setHasPromptedOnboarding] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium");
@@ -278,21 +290,6 @@ export default function BoardDetailPage() {
};
}, [board, boardId, getToken, isSignedIn, selectedTask?.id]);
useEffect(() => {
if (!board) return;
if (board.board_type === "general") {
setIsOnboardingOpen(false);
return;
}
if (!board.goal_confirmed && !hasPromptedOnboarding) {
setIsOnboardingOpen(true);
setHasPromptedOnboarding(true);
}
if (board.goal_confirmed) {
setIsOnboardingOpen(false);
}
}, [board, hasPromptedOnboarding]);
const resetForm = () => {
setTitle("");
setDescription("");
@@ -427,13 +424,8 @@ export default function BoardDetailPage() {
setCommentsError(null);
};
const handleOnboardingConfirmed = (updated: Board) => {
setBoard(updated);
setIsOnboardingOpen(false);
};
const agentInitials = (name: string) =>
name
const agentInitials = (agent: Agent) =>
agent.name
.split(" ")
.filter(Boolean)
.slice(0, 2)
@@ -441,6 +433,21 @@ export default function BoardDetailPage() {
.join("")
.toUpperCase();
const resolveEmoji = (value?: string | null) => {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (EMOJI_GLYPHS[trimmed]) return EMOJI_GLYPHS[trimmed];
if (trimmed.startsWith(":") && trimmed.endsWith(":")) return null;
return trimmed;
};
const agentAvatarLabel = (agent: Agent) => {
if (agent.is_board_lead) return "⚙️";
const emoji = resolveEmoji(agent.identity_profile?.emoji ?? null);
return emoji ?? agentInitials(agent);
};
const agentStatusLabel = (agent: Agent) => {
if (workingAgentIds.has(agent.id)) return "Working";
if (agent.status === "online") return "Active";
@@ -502,6 +509,15 @@ export default function BoardDetailPage() {
Timeline
</button>
</div>
<Button onClick={() => setIsDialogOpen(true)}>
New task
</Button>
<Button
variant="outline"
onClick={() => router.push(`/boards/${boardId}/edit`)}
>
Board settings
</Button>
<Button
variant="outline"
onClick={() => router.push("/boards")}
@@ -541,14 +557,16 @@ export default function BoardDetailPage() {
sortedAgents.map((agent) => {
const isWorking = workingAgentIds.has(agent.id);
return (
<div
<button
key={agent.id}
type="button"
className={cn(
"flex items-center gap-3 rounded-lg border border-transparent px-2 py-2 transition hover:border-slate-200 hover:bg-slate-50",
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
)}
onClick={() => router.push(`/agents/${agent.id}`)}
>
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{agentInitials(agent.name)}
{agentAvatarLabel(agent)}
<span
className={cn(
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
@@ -568,7 +586,7 @@ export default function BoardDetailPage() {
{agentStatusLabel(agent)}
</p>
</div>
</div>
</button>
);
})
)}
@@ -576,17 +594,6 @@ export default function BoardDetailPage() {
</aside>
<div className="min-w-0 flex-1 space-y-6">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<BoardGoalPanel
board={board}
onStartOnboarding={() => setIsOnboardingOpen(true)}
onEdit={
boardId ? () => router.push(`/boards/${boardId}/edit`) : undefined
}
/>
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
</div>
{error && (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{error}
@@ -789,28 +796,7 @@ export default function BoardDetailPage() {
</DialogContent>
</Dialog>
<Dialog
open={isOnboardingOpen}
onOpenChange={(nextOpen) => {
setIsOnboardingOpen(nextOpen);
if (!nextOpen) {
setHasPromptedOnboarding(true);
}
}}
>
<DialogContent aria-label="Board onboarding">
{boardId ? (
<BoardOnboardingChat
boardId={boardId}
onConfirmed={handleOnboardingConfirmed}
/>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
Unable to start onboarding.
</div>
)}
</DialogContent>
</Dialog>
{/* onboarding moved to board settings */}
</DashboardShell>
);
}

View File

@@ -29,6 +29,7 @@ type Agent = {
board_id?: string | null;
last_seen_at?: string | null;
updated_at: string;
is_board_lead?: boolean;
};
type GatewayStatus = {

View File

@@ -49,17 +49,43 @@ type Question = {
options: QuestionOption[];
};
const normalizeQuestion = (value: unknown): Question | null => {
if (!value || typeof value !== "object") return null;
const data = value as { question?: unknown; options?: unknown };
if (typeof data.question !== "string" || !Array.isArray(data.options)) return null;
const options: QuestionOption[] = data.options
.map((option, index) => {
if (typeof option === "string") {
return { id: String(index + 1), label: option };
}
if (option && typeof option === "object") {
const raw = option as { id?: unknown; label?: unknown };
const label =
typeof raw.label === "string" ? raw.label : typeof raw.id === "string" ? raw.id : null;
if (!label) return null;
return {
id: typeof raw.id === "string" ? raw.id : String(index + 1),
label,
};
}
return null;
})
.filter((option): option is QuestionOption => Boolean(option));
if (!options.length) return null;
return { question: data.question, options };
};
const parseQuestion = (messages?: Array<{ role: string; content: string }> | null) => {
if (!messages?.length) return null;
const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
if (!lastAssistant?.content) return null;
try {
return JSON.parse(lastAssistant.content) as Question;
return normalizeQuestion(JSON.parse(lastAssistant.content));
} catch {
const match = lastAssistant.content.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
try {
return JSON.parse(match[1]) as Question;
return normalizeQuestion(JSON.parse(match[1]));
} catch {
return null;
}
@@ -80,10 +106,16 @@ export function BoardOnboardingChat({
const [loading, setLoading] = useState(false);
const [otherText, setOtherText] = useState("");
const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const question = useMemo(() => parseQuestion(session?.messages), [session]);
const draft = session?.draft_goal ?? null;
useEffect(() => {
setSelectedOptions([]);
setOtherText("");
}, [question?.question]);
const authFetch = useCallback(
async (url: string, options: RequestInit = {}) => {
const token = await getToken();
@@ -162,6 +194,20 @@ export function BoardOnboardingChat({
[authFetch, boardId]
);
const toggleOption = useCallback((label: string) => {
setSelectedOptions((prev) =>
prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label]
);
}, []);
const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim();
const answer =
selectedOptions.length > 0 ? selectedOptions.join(", ") : "Other";
if (!answer && !trimmedOther) return;
void handleAnswer(answer, trimmedOther || undefined);
}, [handleAnswer, otherText, selectedOptions]);
const confirmGoal = async () => {
if (!draft) return;
setLoading(true);
@@ -228,17 +274,20 @@ export function BoardOnboardingChat({
<div className="space-y-3">
<p className="text-sm font-medium text-slate-900">{question.question}</p>
<div className="space-y-2">
{question.options.map((option) => (
<Button
key={option.id}
variant="secondary"
className="w-full justify-start"
onClick={() => handleAnswer(option.label)}
disabled={loading}
>
{option.label}
</Button>
))}
{question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label);
return (
<Button
key={option.id}
variant={isSelected ? "primary" : "secondary"}
className="w-full justify-start"
onClick={() => toggleOption(option.label)}
disabled={loading}
>
{option.label}
</Button>
);
})}
</div>
<div className="space-y-2">
<Input
@@ -248,14 +297,17 @@ export function BoardOnboardingChat({
/>
<Button
variant="outline"
onClick={() => {
const trimmed = otherText.trim();
void handleAnswer(trimmed || "Other", trimmed || undefined);
}}
disabled={loading || !otherText.trim()}
onClick={submitAnswer}
disabled={
loading ||
(selectedOptions.length === 0 && !otherText.trim())
}
>
Submit other
{loading ? "Sending..." : "Next"}
</Button>
{loading ? (
<p className="text-xs text-slate-500">Sending your answer</p>
) : null}
</div>
</div>
) : (