feat: add board goal editor and onboarding chat
This commit is contained in:
@@ -9,7 +9,15 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
|||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import SearchableSelect from "@/components/ui/searchable-select";
|
import SearchableSelect from "@/components/ui/searchable-select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getApiBaseUrl } from "@/lib/api-base";
|
import { getApiBaseUrl } from "@/lib/api-base";
|
||||||
|
|
||||||
const apiBase = getApiBaseUrl();
|
const apiBase = getApiBaseUrl();
|
||||||
@@ -19,6 +27,10 @@ type Board = {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gateway_id?: string | null;
|
gateway_id?: string | null;
|
||||||
|
board_type?: string;
|
||||||
|
objective?: string | null;
|
||||||
|
success_metrics?: Record<string, unknown> | null;
|
||||||
|
target_date?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Gateway = {
|
type Gateway = {
|
||||||
@@ -36,6 +48,13 @@ const slugify = (value: string) =>
|
|||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "") || "board";
|
.replace(/(^-|-$)/g, "") || "board";
|
||||||
|
|
||||||
|
const toDateInput = (value?: string | null) => {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
export default function EditBoardPage() {
|
export default function EditBoardPage() {
|
||||||
const { getToken, isSignedIn } = useAuth();
|
const { getToken, isSignedIn } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -47,9 +66,14 @@ export default function EditBoardPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [gateways, setGateways] = useState<Gateway[]>([]);
|
const [gateways, setGateways] = useState<Gateway[]>([]);
|
||||||
const [gatewayId, setGatewayId] = useState<string>("");
|
const [gatewayId, setGatewayId] = useState<string>("");
|
||||||
|
const [boardType, setBoardType] = useState("goal");
|
||||||
|
const [objective, setObjective] = useState("");
|
||||||
|
const [successMetrics, setSuccessMetrics] = useState("");
|
||||||
|
const [targetDate, setTargetDate] = useState("");
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [metricsError, setMetricsError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isFormReady = Boolean(name.trim() && gatewayId);
|
const isFormReady = Boolean(name.trim() && gatewayId);
|
||||||
|
|
||||||
@@ -88,6 +112,12 @@ export default function EditBoardPage() {
|
|||||||
if (data.gateway_id) {
|
if (data.gateway_id) {
|
||||||
setGatewayId(data.gateway_id);
|
setGatewayId(data.gateway_id);
|
||||||
}
|
}
|
||||||
|
setBoardType(data.board_type ?? "goal");
|
||||||
|
setObjective(data.objective ?? "");
|
||||||
|
setSuccessMetrics(
|
||||||
|
data.success_metrics ? JSON.stringify(data.success_metrics, null, 2) : ""
|
||||||
|
);
|
||||||
|
setTargetDate(toDateInput(data.target_date));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||||
}
|
}
|
||||||
@@ -126,8 +156,19 @@ export default function EditBoardPage() {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMetricsError(null);
|
||||||
try {
|
try {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
|
let parsedMetrics: Record<string, unknown> | null = null;
|
||||||
|
if (successMetrics.trim()) {
|
||||||
|
try {
|
||||||
|
parsedMetrics = JSON.parse(successMetrics) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
setMetricsError("Success metrics must be valid JSON.");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
|
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -139,6 +180,10 @@ export default function EditBoardPage() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
slug: slugify(name.trim()),
|
slug: slugify(name.trim()),
|
||||||
gateway_id: gatewayId || null,
|
gateway_id: gatewayId || null,
|
||||||
|
board_type: boardType,
|
||||||
|
objective: objective.trim() || null,
|
||||||
|
success_metrics: parsedMetrics,
|
||||||
|
target_date: targetDate ? new Date(targetDate).toISOString() : null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -219,6 +264,66 @@ export default function EditBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{gateways.length === 0 ? (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
<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>
|
<p>No gateways available. Create one in Gateways to continue.</p>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
|
||||||
|
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
@@ -35,6 +37,11 @@ type Board = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
board_type?: string;
|
||||||
|
objective?: string | null;
|
||||||
|
success_metrics?: Record<string, unknown> | null;
|
||||||
|
target_date?: string | null;
|
||||||
|
goal_confirmed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Task = {
|
type Task = {
|
||||||
@@ -92,6 +99,8 @@ export default function BoardDetailPage() {
|
|||||||
const tasksRef = useRef<Task[]>([]);
|
const tasksRef = useRef<Task[]>([]);
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||||
|
const [hasPromptedOnboarding, setHasPromptedOnboarding] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [priority, setPriority] = useState("medium");
|
const [priority, setPriority] = useState("medium");
|
||||||
@@ -266,7 +275,22 @@ export default function BoardDetailPage() {
|
|||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
}, [board, boardId, getToken, isSignedIn]);
|
}, [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 = () => {
|
const resetForm = () => {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
@@ -402,6 +426,11 @@ export default function BoardDetailPage() {
|
|||||||
setCommentsError(null);
|
setCommentsError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOnboardingConfirmed = (updated: Board) => {
|
||||||
|
setBoard(updated);
|
||||||
|
setIsOnboardingOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const agentInitials = (name: string) =>
|
const agentInitials = (name: string) =>
|
||||||
name
|
name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -429,7 +458,6 @@ export default function BoardDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell>
|
<DashboardShell>
|
||||||
<SignedOut>
|
<SignedOut>
|
||||||
@@ -546,9 +574,19 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 space-y-6">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<BoardGoalPanel
|
||||||
|
board={board}
|
||||||
|
onStartOnboarding={() => setIsOnboardingOpen(true)}
|
||||||
|
onEdit={
|
||||||
|
boardId ? () => router.push(`/boards/${boardId}/edit`) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -748,6 +786,29 @@ export default function BoardDetailPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
145
frontend/src/components/BoardGoalPanel.tsx
Normal file
145
frontend/src/components/BoardGoalPanel.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type BoardGoal = {
|
||||||
|
board_type?: string;
|
||||||
|
objective?: string | null;
|
||||||
|
success_metrics?: Record<string, unknown> | null;
|
||||||
|
target_date?: string | null;
|
||||||
|
goal_confirmed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoardGoalPanelProps = {
|
||||||
|
board?: BoardGoal | null;
|
||||||
|
onStartOnboarding?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTargetDate = (value?: string | null) => {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BoardGoalPanel({
|
||||||
|
board,
|
||||||
|
onStartOnboarding,
|
||||||
|
onEdit,
|
||||||
|
}: BoardGoalPanelProps) {
|
||||||
|
const metricsEntries = (() => {
|
||||||
|
if (!board?.success_metrics) return [];
|
||||||
|
if (Array.isArray(board.success_metrics)) {
|
||||||
|
return board.success_metrics.map((value, index) => [
|
||||||
|
`Metric ${index + 1}`,
|
||||||
|
value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (typeof board.success_metrics === "object") {
|
||||||
|
return Object.entries(board.success_metrics);
|
||||||
|
}
|
||||||
|
return [["Metric", board.success_metrics]];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isGoalBoard = board?.board_type !== "general";
|
||||||
|
const isConfirmed = Boolean(board?.goal_confirmed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4 border-b border-[color:var(--border)] pb-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
Board goal
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-strong">
|
||||||
|
{board ? "Mission overview" : "Loading board goal"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{board ? (
|
||||||
|
<>
|
||||||
|
<Badge variant={isGoalBoard ? "accent" : "outline"}>
|
||||||
|
{isGoalBoard ? "Goal board" : "General board"}
|
||||||
|
</Badge>
|
||||||
|
{isGoalBoard ? (
|
||||||
|
<Badge variant={isConfirmed ? "success" : "warning"}>
|
||||||
|
{isConfirmed ? "Confirmed" : "Needs confirmation"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{board ? (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{isGoalBoard
|
||||||
|
? "Track progress against the board objective and keep agents aligned."
|
||||||
|
: "General boards focus on tasks without formal success metrics."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-32 animate-pulse rounded-full bg-[color:var(--surface-muted)]" />
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pt-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
Objective
|
||||||
|
</p>
|
||||||
|
<p className={cn("text-sm", board?.objective ? "text-strong" : "text-muted")}>
|
||||||
|
{board?.objective || (isGoalBoard ? "No objective yet." : "Not required.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
Success metrics
|
||||||
|
</p>
|
||||||
|
{metricsEntries.length > 0 ? (
|
||||||
|
<ul className="space-y-1 text-sm text-strong">
|
||||||
|
{metricsEntries.map(([key, value]) => (
|
||||||
|
<li key={`${key}`} className="flex gap-2">
|
||||||
|
<span className="font-medium text-strong">{key}:</span>
|
||||||
|
<span className="text-muted">{String(value)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{isGoalBoard ? "No metrics defined yet." : "Not required."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
Target date
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-strong">{formatTargetDate(board?.target_date)}</p>
|
||||||
|
</div>
|
||||||
|
{onStartOnboarding || onEdit ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{onStartOnboarding && isGoalBoard && !isConfirmed ? (
|
||||||
|
<Button variant="primary" onClick={onStartOnboarding}>
|
||||||
|
Start onboarding
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onEdit ? (
|
||||||
|
<Button variant="secondary" onClick={onEdit}>
|
||||||
|
Edit board
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoardGoalPanel;
|
||||||
266
frontend/src/components/BoardOnboardingChat.tsx
Normal file
266
frontend/src/components/BoardOnboardingChat.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { getApiBaseUrl } from "@/lib/api-base";
|
||||||
|
|
||||||
|
const apiBase = getApiBaseUrl();
|
||||||
|
|
||||||
|
type BoardDraft = {
|
||||||
|
board_type?: string;
|
||||||
|
objective?: string | null;
|
||||||
|
success_metrics?: Record<string, unknown> | null;
|
||||||
|
target_date?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoardSummary = {
|
||||||
|
id: string;
|
||||||
|
board_type?: string;
|
||||||
|
objective?: string | null;
|
||||||
|
success_metrics?: Record<string, unknown> | null;
|
||||||
|
target_date?: string | null;
|
||||||
|
goal_confirmed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OnboardingSession = {
|
||||||
|
id: string;
|
||||||
|
board_id: string;
|
||||||
|
session_key: string;
|
||||||
|
status: string;
|
||||||
|
messages?: Array<{ role: string; content: string }> | null;
|
||||||
|
draft_goal?: BoardDraft | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuestionOption = { id: string; label: string };
|
||||||
|
|
||||||
|
type Question = {
|
||||||
|
question: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
} catch {
|
||||||
|
const match = lastAssistant.content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[1]) as Question;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BoardOnboardingChat({
|
||||||
|
boardId,
|
||||||
|
onConfirmed,
|
||||||
|
}: {
|
||||||
|
boardId: string;
|
||||||
|
onConfirmed: (board: BoardSummary) => void;
|
||||||
|
}) {
|
||||||
|
const { getToken } = useAuth();
|
||||||
|
const [session, setSession] = useState<OnboardingSession | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [otherText, setOtherText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const question = useMemo(() => parseQuestion(session?.messages), [session]);
|
||||||
|
const draft = session?.draft_goal ?? null;
|
||||||
|
|
||||||
|
const authFetch = useCallback(
|
||||||
|
async (url: string, options: RequestInit = {}) => {
|
||||||
|
const token = await getToken();
|
||||||
|
return fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startSession = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding/start`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Unable to start onboarding.");
|
||||||
|
const data = (await res.json()) as OnboardingSession;
|
||||||
|
setSession(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to start onboarding.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [authFetch, boardId]);
|
||||||
|
|
||||||
|
const refreshSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as OnboardingSession;
|
||||||
|
setSession(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [authFetch, boardId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startSession();
|
||||||
|
const interval = setInterval(refreshSession, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [startSession, refreshSession]);
|
||||||
|
|
||||||
|
const handleAnswer = useCallback(
|
||||||
|
async (value: string, freeText?: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${apiBase}/api/v1/boards/${boardId}/onboarding/answer`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
answer: value,
|
||||||
|
other_text: freeText ?? null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Unable to submit answer.");
|
||||||
|
const data = (await res.json()) as OnboardingSession;
|
||||||
|
setSession(data);
|
||||||
|
setOtherText("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to submit answer.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authFetch, boardId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmGoal = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${apiBase}/api/v1/boards/${boardId}/onboarding/confirm`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
board_type: draft.board_type ?? "goal",
|
||||||
|
objective: draft.objective ?? null,
|
||||||
|
success_metrics: draft.success_metrics ?? null,
|
||||||
|
target_date: draft.target_date ?? null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Unable to confirm board goal.");
|
||||||
|
const updated = await res.json();
|
||||||
|
onConfirmed(updated);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to confirm board goal.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Board onboarding</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{draft ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Review the lead agent draft and confirm.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
|
<p className="font-semibold text-slate-900">Objective</p>
|
||||||
|
<p className="text-slate-700">{draft.objective || "—"}</p>
|
||||||
|
<p className="mt-3 font-semibold text-slate-900">Success metrics</p>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap text-xs text-slate-600">
|
||||||
|
{JSON.stringify(draft.success_metrics ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
<p className="mt-3 font-semibold text-slate-900">Target date</p>
|
||||||
|
<p className="text-slate-700">{draft.target_date || "—"}</p>
|
||||||
|
<p className="mt-3 font-semibold text-slate-900">Board type</p>
|
||||||
|
<p className="text-slate-700">{draft.board_type || "goal"}</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={confirmGoal} disabled={loading}>
|
||||||
|
Confirm goal
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : question ? (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Other..."
|
||||||
|
value={otherText}
|
||||||
|
onChange={(event) => setOtherText(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = otherText.trim();
|
||||||
|
void handleAnswer(trimmed || "Other", trimmed || undefined);
|
||||||
|
}}
|
||||||
|
disabled={loading || !otherText.trim()}
|
||||||
|
>
|
||||||
|
Submit other
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||||
|
{loading ? "Waiting for the lead agent..." : "Preparing onboarding..."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user