feat: allow extra context during board onboarding

This commit is contained in:
Abhimanyu Saharan
2026-02-07 03:40:28 +05:30
parent 19323e25de
commit e71507e0bf
3 changed files with 291 additions and 117 deletions

View File

@@ -180,6 +180,10 @@ async def start_onboarding(
"- 1 question to choose a unique name for the board lead agent (first-name style).\n" "- 1 question to choose a unique name for the board lead agent (first-name style).\n"
"- 2-4 questions to capture the user's preferences for how the board lead should work\n" "- 2-4 questions to capture the user's preferences for how the board lead should work\n"
" (communication style, autonomy, update cadence, and output formatting).\n" " (communication style, autonomy, update cadence, and output formatting).\n"
'- Always include a final question: "Anything else we should know?" (constraints, context,\n'
' preferences). Provide an option like "Yes (I\'ll type it)" so they can fill the free-text field.\n'
'- If the user sends an "Additional context" message later, incorporate it and resend status=complete\n'
" to update the draft (until the user confirms).\n"
"Do NOT respond in OpenClaw chat.\n" "Do NOT respond in OpenClaw chat.\n"
"All onboarding responses MUST be sent to Mission Control via API.\n" "All onboarding responses MUST be sent to Mission Control via API.\n"
f"Mission Control base URL: {base_url}\n" f"Mission Control base URL: {base_url}\n"

View File

@@ -95,7 +95,9 @@ def _error_payload(*, detail: Any, request_id: str | None) -> dict[str, Any]:
return payload return payload
async def _request_validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse: async def _request_validation_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
# `RequestValidationError` is expected user input; don't log at ERROR. # `RequestValidationError` is expected user input; don't log at ERROR.
request_id = _get_request_id(request) request_id = _get_request_id(request)
return JSONResponse( return JSONResponse(
@@ -104,7 +106,9 @@ async def _request_validation_handler(request: Request, exc: RequestValidationEr
) )
async def _response_validation_handler(request: Request, exc: ResponseValidationError) -> JSONResponse: async def _response_validation_handler(
request: Request, exc: ResponseValidationError
) -> JSONResponse:
request_id = _get_request_id(request) request_id = _get_request_id(request)
logger.exception( logger.exception(
"response_validation_error", "response_validation_error",

View File

@@ -9,6 +9,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
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 { Textarea } from "@/components/ui/textarea";
import { import {
answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost, answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost,
@@ -55,7 +56,8 @@ type Question = {
const normalizeQuestion = (value: unknown): Question | null => { const normalizeQuestion = (value: unknown): Question | null => {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const data = value as { question?: unknown; options?: unknown }; const data = value as { question?: unknown; options?: unknown };
if (typeof data.question !== "string" || !Array.isArray(data.options)) return null; if (typeof data.question !== "string" || !Array.isArray(data.options))
return null;
const options: QuestionOption[] = data.options const options: QuestionOption[] = data.options
.map((option, index) => { .map((option, index) => {
if (typeof option === "string") { if (typeof option === "string") {
@@ -64,7 +66,11 @@ const normalizeQuestion = (value: unknown): Question | null => {
if (option && typeof option === "object") { if (option && typeof option === "object") {
const raw = option as { id?: unknown; label?: unknown }; const raw = option as { id?: unknown; label?: unknown };
const label = const label =
typeof raw.label === "string" ? raw.label : typeof raw.id === "string" ? raw.id : null; typeof raw.label === "string"
? raw.label
: typeof raw.id === "string"
? raw.id
: null;
if (!label) return null; if (!label) return null;
return { return {
id: typeof raw.id === "string" ? raw.id : String(index + 1), id: typeof raw.id === "string" ? raw.id : String(index + 1),
@@ -80,7 +86,9 @@ const normalizeQuestion = (value: unknown): Question | null => {
const parseQuestion = (messages?: NormalizedMessage[] | null) => { const parseQuestion = (messages?: NormalizedMessage[] | null) => {
if (!messages?.length) return null; if (!messages?.length) return null;
const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant"); const lastAssistant = [...messages]
.reverse()
.find((msg) => msg.role === "assistant");
if (!lastAssistant?.content) return null; if (!lastAssistant?.content) return null;
try { try {
return normalizeQuestion(JSON.parse(lastAssistant.content)); return normalizeQuestion(JSON.parse(lastAssistant.content));
@@ -107,6 +115,8 @@ export function BoardOnboardingChat({
const [session, setSession] = useState<BoardOnboardingRead | null>(null); const [session, setSession] = useState<BoardOnboardingRead | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [otherText, setOtherText] = useState(""); const [otherText, setOtherText] = useState("");
const [extraContext, setExtraContext] = useState("");
const [extraContextOpen, setExtraContextOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]); const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
@@ -114,14 +124,22 @@ export function BoardOnboardingChat({
() => normalizeMessages(session?.messages), () => normalizeMessages(session?.messages),
[session?.messages], [session?.messages],
); );
const question = useMemo(() => parseQuestion(normalizedMessages), [normalizedMessages]); const question = useMemo(
const draft: BoardOnboardingAgentComplete | null = session?.draft_goal ?? null; () => parseQuestion(normalizedMessages),
[normalizedMessages],
);
const draft: BoardOnboardingAgentComplete | null =
session?.draft_goal ?? null;
useEffect(() => { useEffect(() => {
setSelectedOptions([]); setSelectedOptions([]);
setOtherText(""); setOtherText("");
}, [question?.question]); }, [question?.question]);
useEffect(() => {
if (draft) setExtraContextOpen(true);
}, [draft]);
const startSession = useCallback(async () => { const startSession = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -133,7 +151,9 @@ export function BoardOnboardingChat({
if (result.status !== 200) throw new Error("Unable to start onboarding."); if (result.status !== 200) throw new Error("Unable to start onboarding.");
setSession(result.data); setSession(result.data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to start onboarding."); setError(
err instanceof Error ? err.message : "Failed to start onboarding.",
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -141,7 +161,8 @@ export function BoardOnboardingChat({
const refreshSession = useCallback(async () => { const refreshSession = useCallback(async () => {
try { try {
const result = await getOnboardingApiV1BoardsBoardIdOnboardingGet(boardId); const result =
await getOnboardingApiV1BoardsBoardIdOnboardingGet(boardId);
if (result.status !== 200) return; if (result.status !== 200) return;
setSession(result.data); setSession(result.data);
} catch { } catch {
@@ -160,7 +181,8 @@ export function BoardOnboardingChat({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const result = await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost( const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
boardId, boardId,
{ {
answer: value, answer: value,
@@ -171,7 +193,9 @@ export function BoardOnboardingChat({
setSession(result.data); setSession(result.data);
setOtherText(""); setOtherText("");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit answer."); setError(
err instanceof Error ? err.message : "Failed to submit answer.",
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -181,10 +205,36 @@ export function BoardOnboardingChat({
const toggleOption = useCallback((label: string) => { const toggleOption = useCallback((label: string) => {
setSelectedOptions((prev) => setSelectedOptions((prev) =>
prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label] prev.includes(label)
? prev.filter((item) => item !== label)
: [...prev, label],
); );
}, []); }, []);
const submitExtraContext = useCallback(async () => {
const trimmed = extraContext.trim();
if (!trimmed) return;
setLoading(true);
setError(null);
try {
const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, {
answer: "Additional context",
other_text: trimmed,
});
if (result.status !== 200)
throw new Error("Unable to submit extra context.");
setSession(result.data);
setExtraContext("");
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to submit extra context.",
);
} finally {
setLoading(false);
}
}, [boardId, extraContext]);
const submitAnswer = useCallback(() => { const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim(); const trimmedOther = otherText.trim();
if (selectedOptions.length === 0 && !trimmedOther) return; if (selectedOptions.length === 0 && !trimmedOther) return;
@@ -198,7 +248,8 @@ export function BoardOnboardingChat({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const result = await confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost( const result =
await confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost(
boardId, boardId,
{ {
board_type: draft.board_type ?? "goal", board_type: draft.board_type ?? "goal",
@@ -207,10 +258,13 @@ export function BoardOnboardingChat({
target_date: draft.target_date ?? null, target_date: draft.target_date ?? null,
}, },
); );
if (result.status !== 200) throw new Error("Unable to confirm board goal."); if (result.status !== 200)
throw new Error("Unable to confirm board goal.");
onConfirmed(result.data); onConfirmed(result.data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to confirm board goal."); setError(
err instanceof Error ? err.message : "Failed to confirm board goal.",
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -246,9 +300,13 @@ export function BoardOnboardingChat({
<p className="text-slate-700">{draft.board_type || "goal"}</p> <p className="text-slate-700">{draft.board_type || "goal"}</p>
{draft.user_profile ? ( {draft.user_profile ? (
<> <>
<p className="mt-4 font-semibold text-slate-900">User profile</p> <p className="mt-4 font-semibold text-slate-900">
User profile
</p>
<p className="text-slate-700"> <p className="text-slate-700">
<span className="font-medium text-slate-900">Preferred name:</span>{" "} <span className="font-medium text-slate-900">
Preferred name:
</span>{" "}
{draft.user_profile.preferred_name || "—"} {draft.user_profile.preferred_name || "—"}
</p> </p>
<p className="text-slate-700"> <p className="text-slate-700">
@@ -278,7 +336,8 @@ export function BoardOnboardingChat({
<span className="font-medium text-slate-900"> <span className="font-medium text-slate-900">
Communication: Communication:
</span>{" "} </span>{" "}
{draft.lead_agent.identity_profile?.communication_style || "—"} {draft.lead_agent.identity_profile?.communication_style ||
"—"}
</p> </p>
<p className="text-slate-700"> <p className="text-slate-700">
<span className="font-medium text-slate-900">Emoji:</span>{" "} <span className="font-medium text-slate-900">Emoji:</span>{" "}
@@ -317,6 +376,58 @@ export function BoardOnboardingChat({
</> </>
) : null} ) : null}
</div> </div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">
Extra context (optional)
</p>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setExtraContextOpen((prev) => !prev)}
disabled={loading}
>
{extraContextOpen ? "Hide" : "Add"}
</Button>
</div>
{extraContextOpen ? (
<div className="mt-2 space-y-2">
<Textarea
className="min-h-[84px]"
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
value={extraContext}
onChange={(event) => setExtraContext(event.target.value)}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return;
event.preventDefault();
if (loading) return;
void submitExtraContext();
}}
/>
<div className="flex items-center justify-end">
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void submitExtraContext()}
disabled={loading || !extraContext.trim()}
>
{loading ? "Sending..." : "Send context"}
</Button>
</div>
<p className="text-xs text-slate-500">
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
</p>
</div>
) : (
<p className="mt-2 text-xs text-slate-600">
Add anything that wasn&apos;t covered in the agent&apos;s
questions.
</p>
)}
</div>
<DialogFooter> <DialogFooter>
<Button onClick={confirmGoal} disabled={loading}> <Button onClick={confirmGoal} disabled={loading}>
Confirm goal Confirm goal
@@ -325,7 +436,9 @@ export function BoardOnboardingChat({
</div> </div>
) : question ? ( ) : question ? (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium text-slate-900">{question.question}</p> <p className="text-sm font-medium text-slate-900">
{question.question}
</p>
<div className="space-y-2"> <div className="space-y-2">
{question.options.map((option) => { {question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label); const isSelected = selectedOptions.includes(option.label);
@@ -358,8 +471,7 @@ export function BoardOnboardingChat({
variant="outline" variant="outline"
onClick={submitAnswer} onClick={submitAnswer}
disabled={ disabled={
loading || loading || (selectedOptions.length === 0 && !otherText.trim())
(selectedOptions.length === 0 && !otherText.trim())
} }
> >
{loading ? "Sending..." : "Next"} {loading ? "Sending..." : "Next"}
@@ -368,10 +480,64 @@ export function BoardOnboardingChat({
<p className="text-xs text-slate-500">Sending your answer</p> <p className="text-xs text-slate-500">Sending your answer</p>
) : null} ) : null}
</div> </div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">
Extra context (optional)
</p>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setExtraContextOpen((prev) => !prev)}
disabled={loading}
>
{extraContextOpen ? "Hide" : "Add"}
</Button>
</div>
{extraContextOpen ? (
<div className="mt-2 space-y-2">
<Textarea
className="min-h-[84px]"
placeholder="Anything else that will help the agent plan/act? (constraints, context, preferences, links, etc.)"
value={extraContext}
onChange={(event) => setExtraContext(event.target.value)}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return;
event.preventDefault();
if (loading) return;
void submitExtraContext();
}}
/>
<div className="flex items-center justify-end">
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void submitExtraContext()}
disabled={loading || !extraContext.trim()}
>
{loading ? "Sending..." : "Send context"}
</Button>
</div>
<p className="text-xs text-slate-500">
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
</p>
</div>
) : (
<p className="mt-2 text-xs text-slate-600">
Add anything that wasn&apos;t covered in the agent&apos;s
questions.
</p>
)}
</div>
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600"> <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..."} {loading
? "Waiting for the lead agent..."
: "Preparing onboarding..."}
</div> </div>
)} )}
</div> </div>