diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d946f..c7bfec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public - Default **PE Engineering Career Framework** (Graduate → CTO) seeded on empty and demo workspaces - **Cursor Cloud Agents** AI provider — use dashboard `crsr_…` keys via the Cloud Agents API (no-repo agents for digests/drafts) - Guided **/setup** onboarding: team → assign-first roles → optional AI → integrations with credential guides → evidence backfill with progress +- **1:1 sessions** on the person dossier — manual Q&A agenda plus optional AI-suggested questions grounded in evidence ### Changed diff --git a/apps/api/src/ai.ts b/apps/api/src/ai.ts index b52e654..0232ef8 100644 --- a/apps/api/src/ai.ts +++ b/apps/api/src/ai.ts @@ -19,7 +19,8 @@ export type AiFeature = | "peer_synthesize" | "framework_extract" | "meeting_summary" - | "skip_level_talking_points"; + | "skip_level_talking_points" + | "one_on_one_questions"; export type ChatMessage = { role: "system" | "user" | "assistant"; content: string }; diff --git a/apps/api/src/featureRoutes.ts b/apps/api/src/featureRoutes.ts index e99336a..f368562 100644 --- a/apps/api/src/featureRoutes.ts +++ b/apps/api/src/featureRoutes.ts @@ -120,6 +120,16 @@ export function registerFeatureRoutes(app: Hono, helpers: Helpers) { content: `WORKSPACE EVIDENCE (${ctx.scopeLabel}):\n${ctx.lines.join("\n")}\n\nUser question: (preview — actual question sent on confirm)`, }, ]; + } else if (body.feature === "one_on_one_questions" && body.personId) { + const ctx = helpers.buildEvidenceContext(body.personId, body.cycleId); + dataClassesSent = ctx.dataClassesSent; + messages = [ + { role: "system", content: buildSystemPrompt() }, + { + role: "user", + content: `Suggest 1:1 questions.\n\nEVIDENCE:\n${ctx.lines.join("\n") || "(thin evidence)"}`, + }, + ]; } else if (body.personId && body.cycleId) { const ctx = helpers.buildEvidenceContext(body.personId, body.cycleId); dataClassesSent = ctx.dataClassesSent; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3254bea..7d22e91 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -110,6 +110,7 @@ import { extractCitationIds, filterCitationsAgainstAllowlist } from "./citations import { runBiasToneLint } from "./bias.js"; import { mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js"; import { registerP1Routes } from "./p1Routes.js"; +import { registerOneOnOneRoutes } from "./oneOnOnes.js"; import { indexReviewFts } from "./reviewSearch.js"; import { renderPromotionPacketHtml, renderSubjectPacketHtml } from "./packetHtml.js"; import { @@ -4692,6 +4693,8 @@ registerP1Routes(app, { frameworkInsufficient, }); +registerOneOnOneRoutes(app, { buildEvidenceContext }); + registerFeatureRoutes(app, { buildEvidenceContext, buildWorkspaceContext, diff --git a/apps/api/src/oneOnOnes.test.ts b/apps/api/src/oneOnOnes.test.ts new file mode 100644 index 0000000..7c898bb --- /dev/null +++ b/apps/api/src/oneOnOnes.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { parseSuggestedQuestions } from "../src/oneOnOnes.js"; + +describe("parseSuggestedQuestions", () => { + it("parses a JSON array of strings", () => { + const qs = parseSuggestedQuestions(`Here you go:\n["How is capacity?", "What blocked [ach_1]?"]\n`); + assert.deepEqual(qs, ["How is capacity?", "What blocked [ach_1]?"]); + }); + + it("parses objects with question fields", () => { + const qs = parseSuggestedQuestions( + JSON.stringify([{ question: "What went well last sprint?" }, { question: "Any risks?" }]), + ); + assert.deepEqual(qs, ["What went well last sprint?", "Any risks?"]); + }); + + it("falls back to numbered lines with question marks", () => { + const qs = parseSuggestedQuestions(`1. How are you feeling about goals?\n2. What support do you need?\nNote: thin evidence`); + assert.equal(qs.length, 2); + assert.match(qs[0], /feeling about goals/); + }); + + it("returns empty for blank input", () => { + assert.deepEqual(parseSuggestedQuestions(" "), []); + }); +}); diff --git a/apps/api/src/oneOnOnes.ts b/apps/api/src/oneOnOnes.ts new file mode 100644 index 0000000..1310ab3 --- /dev/null +++ b/apps/api/src/oneOnOnes.ts @@ -0,0 +1,424 @@ +import { eq } from "drizzle-orm"; +import { oneOnOneItems, oneOnOneSessions, people } from "@prm/db"; +import type { + OneOnOneItemDTO, + OneOnOneItemSource, + OneOnOneSessionDTO, + OneOnOneSessionStatus, + Visibility, +} from "@prm/shared"; +import type { Hono } from "hono"; +import { buildSystemPrompt, getAiConfig, runChat, storeGeneration, type ChatMessage } from "./ai.js"; +import { filterCitationsAgainstAllowlist } from "./citations.js"; +import { getDb, id, logActivity, nowIso } from "./store.js"; + +const STATUSES = new Set(["planned", "in_progress", "done"]); +const SOURCES = new Set(["manual", "ai_suggested"]); + +type EvidenceCtx = { + lines: string[]; + citations: string[]; + dataClassesSent: string[]; +}; + +type Helpers = { + buildEvidenceContext: (personId: string, cycleId?: string) => EvidenceCtx; +}; + +function toItemDto(row: typeof oneOnOneItems.$inferSelect): OneOnOneItemDTO { + return { + id: row.id, + sessionId: row.sessionId, + sortOrder: row.sortOrder, + questionText: row.questionText, + answerText: row.answerText, + source: (SOURCES.has(row.source as OneOnOneItemSource) + ? row.source + : "manual") as OneOnOneItemSource, + generationId: row.generationId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toSessionDto( + row: typeof oneOnOneSessions.$inferSelect, + items: OneOnOneItemDTO[], +): OneOnOneSessionDTO { + return { + id: row.id, + personId: row.personId, + occurredAt: row.occurredAt, + title: row.title, + status: (STATUSES.has(row.status as OneOnOneSessionStatus) + ? row.status + : "planned") as OneOnOneSessionStatus, + notes: row.notes, + visibility: (row.visibility as Visibility) || "em_only", + items, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function itemsForSession(sessionId: string): OneOnOneItemDTO[] { + return getDb() + .select() + .from(oneOnOneItems) + .where(eq(oneOnOneItems.sessionId, sessionId)) + .all() + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt.localeCompare(b.createdAt)) + .map(toItemDto); +} + +function loadSession(sessionId: string): OneOnOneSessionDTO | null { + const row = getDb().select().from(oneOnOneSessions).where(eq(oneOnOneSessions.id, sessionId)).all()[0]; + if (!row) return null; + return toSessionDto(row, itemsForSession(sessionId)); +} + +function nextSortOrder(sessionId: string): number { + const rows = getDb() + .select() + .from(oneOnOneItems) + .where(eq(oneOnOneItems.sessionId, sessionId)) + .all(); + if (!rows.length) return 0; + return Math.max(...rows.map((r) => r.sortOrder)) + 1; +} + +/** Parse AI output into question strings (JSON array preferred; fallback to lines). */ +export function parseSuggestedQuestions(text: string): string[] { + const trimmed = text.trim(); + if (!trimmed) return []; + try { + const jsonMatch = trimmed.match(/\[[\s\S]*\]/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]) as unknown; + if (Array.isArray(parsed)) { + return parsed + .map((q) => { + if (typeof q === "string") return q.trim(); + if (q && typeof q === "object" && typeof (q as { question?: string }).question === "string") { + return String((q as { question: string }).question).trim(); + } + return ""; + }) + .filter(Boolean); + } + } + } catch { + /* fall through */ + } + return trimmed + .split(/\r?\n/) + .map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").trim()) + .filter((line) => line.length > 8 && /\?/.test(line)); +} + +export function registerOneOnOneRoutes(app: Hono, helpers: Helpers) { + app.get("/api/people/:id/one-on-ones", (c) => { + const personId = c.req.param("id"); + const rows = getDb() + .select() + .from(oneOnOneSessions) + .where(eq(oneOnOneSessions.personId, personId)) + .all() + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || b.createdAt.localeCompare(a.createdAt)); + return c.json({ + items: rows.map((row) => toSessionDto(row, itemsForSession(row.id))), + }); + }); + + app.post("/api/people/:id/one-on-ones", async (c) => { + const personId = c.req.param("id"); + const db = getDb(); + const person = db.select().from(people).where(eq(people.id, personId)).all()[0]; + if (!person) return c.json({ error: "Person not found" }, 404); + + const body = await c.req.json<{ + title?: string; + occurredAt?: string; + status?: OneOnOneSessionStatus; + notes?: string; + questions?: string[]; + }>(); + + const ts = nowIso(); + const sessionId = id("ono"); + const title = (body.title ?? `1:1 — ${person.name}`).trim() || `1:1 — ${person.name}`; + const occurredAt = (body.occurredAt ?? ts.slice(0, 10)).trim(); + const status: OneOnOneSessionStatus = + body.status && STATUSES.has(body.status) ? body.status : "planned"; + + db.insert(oneOnOneSessions) + .values({ + id: sessionId, + personId, + occurredAt, + title, + status, + notes: body.notes?.trim() || null, + visibility: "em_only", + createdAt: ts, + updatedAt: ts, + }) + .run(); + + const seedQuestions = (body.questions ?? []) + .map((q) => q.trim()) + .filter(Boolean) + .slice(0, 40); + seedQuestions.forEach((questionText, i) => { + db.insert(oneOnOneItems) + .values({ + id: id("onoq"), + sessionId, + sortOrder: i, + questionText, + answerText: null, + source: "manual", + generationId: null, + createdAt: ts, + updatedAt: ts, + }) + .run(); + }); + + logActivity("one_on_one.create", "one_on_one_session", sessionId, { + personId, + questionCount: seedQuestions.length, + }); + return c.json(loadSession(sessionId)); + }); + + app.patch("/api/one-on-ones/:id", async (c) => { + const sessionId = c.req.param("id"); + const db = getDb(); + const existing = db.select().from(oneOnOneSessions).where(eq(oneOnOneSessions.id, sessionId)).all()[0]; + if (!existing) return c.json({ error: "Session not found" }, 404); + + const body = await c.req.json<{ + title?: string; + occurredAt?: string; + status?: OneOnOneSessionStatus; + notes?: string | null; + }>(); + + const patch: Partial = { updatedAt: nowIso() }; + if (typeof body.title === "string" && body.title.trim()) patch.title = body.title.trim(); + if (typeof body.occurredAt === "string" && body.occurredAt.trim()) { + patch.occurredAt = body.occurredAt.trim(); + } + if (body.status && STATUSES.has(body.status)) patch.status = body.status; + if (body.notes !== undefined) patch.notes = body.notes?.trim() || null; + + db.update(oneOnOneSessions).set(patch).where(eq(oneOnOneSessions.id, sessionId)).run(); + logActivity("one_on_one.update", "one_on_one_session", sessionId, { + status: patch.status ?? existing.status, + }); + return c.json(loadSession(sessionId)); + }); + + app.delete("/api/one-on-ones/:id", (c) => { + const sessionId = c.req.param("id"); + const db = getDb(); + const existing = db.select().from(oneOnOneSessions).where(eq(oneOnOneSessions.id, sessionId)).all()[0]; + if (!existing) return c.json({ error: "Session not found" }, 404); + db.delete(oneOnOneItems).where(eq(oneOnOneItems.sessionId, sessionId)).run(); + db.delete(oneOnOneSessions).where(eq(oneOnOneSessions.id, sessionId)).run(); + logActivity("one_on_one.delete", "one_on_one_session", sessionId, { personId: existing.personId }); + return c.json({ ok: true }); + }); + + app.post("/api/one-on-ones/:id/items", async (c) => { + const sessionId = c.req.param("id"); + const db = getDb(); + const session = db.select().from(oneOnOneSessions).where(eq(oneOnOneSessions.id, sessionId)).all()[0]; + if (!session) return c.json({ error: "Session not found" }, 404); + + const body = await c.req.json<{ + questionText?: string; + answerText?: string | null; + source?: OneOnOneItemSource; + generationId?: string | null; + }>(); + const questionText = (body.questionText ?? "").trim(); + if (!questionText) return c.json({ error: "questionText required" }, 400); + + const ts = nowIso(); + const itemId = id("onoq"); + const source: OneOnOneItemSource = + body.source && SOURCES.has(body.source) ? body.source : "manual"; + db.insert(oneOnOneItems) + .values({ + id: itemId, + sessionId, + sortOrder: nextSortOrder(sessionId), + questionText, + answerText: body.answerText?.trim() || null, + source, + generationId: body.generationId ?? null, + createdAt: ts, + updatedAt: ts, + }) + .run(); + db.update(oneOnOneSessions) + .set({ updatedAt: ts }) + .where(eq(oneOnOneSessions.id, sessionId)) + .run(); + logActivity("one_on_one.item_create", "one_on_one_item", itemId, { sessionId, source }); + const row = db.select().from(oneOnOneItems).where(eq(oneOnOneItems.id, itemId)).all()[0]; + return c.json(toItemDto(row)); + }); + + app.patch("/api/one-on-one-items/:id", async (c) => { + const itemId = c.req.param("id"); + const db = getDb(); + const existing = db.select().from(oneOnOneItems).where(eq(oneOnOneItems.id, itemId)).all()[0]; + if (!existing) return c.json({ error: "Item not found" }, 404); + + const body = await c.req.json<{ + questionText?: string; + answerText?: string | null; + sortOrder?: number; + }>(); + const patch: Partial = { updatedAt: nowIso() }; + if (typeof body.questionText === "string" && body.questionText.trim()) { + patch.questionText = body.questionText.trim(); + } + if (body.answerText !== undefined) patch.answerText = body.answerText?.trim() || null; + if (typeof body.sortOrder === "number" && Number.isFinite(body.sortOrder)) { + patch.sortOrder = Math.max(0, Math.floor(body.sortOrder)); + } + + db.update(oneOnOneItems).set(patch).where(eq(oneOnOneItems.id, itemId)).run(); + db.update(oneOnOneSessions) + .set({ updatedAt: nowIso() }) + .where(eq(oneOnOneSessions.id, existing.sessionId)) + .run(); + logActivity("one_on_one.item_update", "one_on_one_item", itemId, { sessionId: existing.sessionId }); + const row = db.select().from(oneOnOneItems).where(eq(oneOnOneItems.id, itemId)).all()[0]; + return c.json(toItemDto(row)); + }); + + app.delete("/api/one-on-one-items/:id", (c) => { + const itemId = c.req.param("id"); + const db = getDb(); + const existing = db.select().from(oneOnOneItems).where(eq(oneOnOneItems.id, itemId)).all()[0]; + if (!existing) return c.json({ error: "Item not found" }, 404); + db.delete(oneOnOneItems).where(eq(oneOnOneItems.id, itemId)).run(); + db.update(oneOnOneSessions) + .set({ updatedAt: nowIso() }) + .where(eq(oneOnOneSessions.id, existing.sessionId)) + .run(); + logActivity("one_on_one.item_delete", "one_on_one_item", itemId, { sessionId: existing.sessionId }); + return c.json({ ok: true }); + }); + + /** + * Suggest 1:1 questions from dossier evidence. Does not auto-write answers. + * Pass addToSession: true to append accepted questions onto the session. + */ + app.post("/api/one-on-ones/:id/suggest-questions", async (c) => { + const sessionId = c.req.param("id"); + const db = getDb(); + const session = db.select().from(oneOnOneSessions).where(eq(oneOnOneSessions.id, sessionId)).all()[0]; + if (!session) return c.json({ error: "Session not found" }, 404); + const person = db.select().from(people).where(eq(people.id, session.personId)).all()[0]; + if (!person) return c.json({ error: "Person not found" }, 404); + + const body = await c.req.json<{ + cycleId?: string | null; + count?: number; + addToSession?: boolean; + }>(); + const count = Math.min(12, Math.max(3, Number(body.count) || 6)); + const cfg = getAiConfig(); + if (!cfg.enabled) { + return c.json({ error: "AI is disabled — add questions manually, or enable AI in Settings." }, 400); + } + + const ctx = helpers.buildEvidenceContext(session.personId, body.cycleId || undefined); + const existingQs = itemsForSession(sessionId).map((i) => i.questionText); + const messages: ChatMessage[] = [ + { + role: "system", + content: `${buildSystemPrompt()} + +You prepare 1:1 questions for a solo engineering manager. +Return ONLY a JSON array of ${count} short question strings (no markdown fence). +Ground questions in the evidence; cite ids in brackets when a question depends on a specific item. +If evidence is thin, ask clarifying / capture questions and say so in one of the questions. +Do not invent projects or metrics.`, + }, + { + role: "user", + content: `Person: ${person.name}${person.title ? ` (${person.title})` : ""} +Session: ${session.title} (${session.occurredAt}) +Existing questions already on the agenda (do not duplicate):\n${existingQs.length ? existingQs.map((q) => `- ${q}`).join("\n") : "(none)"} + +WORKSPACE EVIDENCE: +${ctx.lines.join("\n") || "(no evidence yet — suggest discovery questions)"}`, + }, + ]; + + try { + const result = await runChat("one_on_one_questions", messages); + const filtered = filterCitationsAgainstAllowlist(result.text, ctx.citations); + const questions = parseSuggestedQuestions(filtered.text).slice(0, count); + if (!questions.length) { + return c.json({ error: "AI returned no usable questions — try again or add manually." }, 500); + } + const generationId = storeGeneration({ + feature: "one_on_one_questions", + subjectPersonId: session.personId, + cycleId: body.cycleId || undefined, + model: result.model, + outputText: filtered.text, + citations: filtered.citationsUsed, + dataClassesSent: ctx.dataClassesSent, + }); + + let added: OneOnOneItemDTO[] = []; + if (body.addToSession) { + const ts = nowIso(); + let order = nextSortOrder(sessionId); + added = questions.map((questionText) => { + const itemId = id("onoq"); + db.insert(oneOnOneItems) + .values({ + id: itemId, + sessionId, + sortOrder: order++, + questionText, + answerText: null, + source: "ai_suggested", + generationId, + createdAt: ts, + updatedAt: ts, + }) + .run(); + return toItemDto(db.select().from(oneOnOneItems).where(eq(oneOnOneItems.id, itemId)).all()[0]); + }); + db.update(oneOnOneSessions) + .set({ updatedAt: ts, status: session.status === "planned" ? "in_progress" : session.status }) + .where(eq(oneOnOneSessions.id, sessionId)) + .run(); + } + + return c.json({ + generationId, + model: result.model, + questions, + citations: filtered.citationsUsed, + citationsDropped: filtered.citationsDropped, + dataClassesSent: ctx.dataClassesSent, + added, + session: loadSession(sessionId), + }); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : "AI suggest failed" }, 500); + } + }); +} diff --git a/apps/ui/src/components/OneOnOnesPanel.tsx b/apps/ui/src/components/OneOnOnesPanel.tsx new file mode 100644 index 0000000..f35c380 --- /dev/null +++ b/apps/ui/src/components/OneOnOnesPanel.tsx @@ -0,0 +1,354 @@ +import { useEffect, useState } from "react"; +import type { OneOnOneItemDTO, OneOnOneSessionDTO, OneOnOneSessionStatus } from "@prm/shared"; +import { api } from "../lib/api"; +import { estimateThenConfirm } from "../lib/aiEstimate"; +import { Section } from "./PageChrome"; + +type Props = { + personId: string; + personName: string; + cycleId?: string; + onCaptureMsg?: (msg: string) => void; +}; + +export function OneOnOnesPanel({ personId, personName, cycleId, onCaptureMsg }: Props) { + const [sessions, setSessions] = useState([]); + const [activeId, setActiveId] = useState(null); + const [title, setTitle] = useState(""); + const [occurredAt, setOccurredAt] = useState(() => new Date().toISOString().slice(0, 10)); + const [manualQuestion, setManualQuestion] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function load(preferId?: string | null) { + const res = await api<{ items: OneOnOneSessionDTO[] }>(`/api/people/${personId}/one-on-ones`); + setSessions(res.items); + const next = + (preferId && res.items.find((s) => s.id === preferId)?.id) || + res.items.find((s) => s.status !== "done")?.id || + res.items[0]?.id || + null; + setActiveId(next); + } + + useEffect(() => { + void load().catch((e) => setError(e instanceof Error ? e.message : "Failed to load 1:1s")); + }, [personId]); + + const active = sessions.find((s) => s.id === activeId) ?? null; + + async function refreshActive() { + if (!activeId) { + await load(); + return; + } + await load(activeId); + } + + async function createSession() { + setBusy(true); + setError(null); + try { + const created = await api(`/api/people/${personId}/one-on-ones`, { + method: "POST", + body: JSON.stringify({ + title: title.trim() || `1:1 — ${personName}`, + occurredAt, + status: "planned", + }), + }); + setTitle(""); + onCaptureMsg?.("1:1 session created"); + await load(created.id); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not create session"); + } finally { + setBusy(false); + } + } + + async function updateSession(patch: Partial<{ title: string; status: OneOnOneSessionStatus; notes: string | null; occurredAt: string }>) { + if (!active) return; + setBusy(true); + setError(null); + try { + await api(`/api/one-on-ones/${active.id}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); + await refreshActive(); + } catch (e) { + setError(e instanceof Error ? e.message : "Update failed"); + } finally { + setBusy(false); + } + } + + async function addManualQuestion() { + if (!active || !manualQuestion.trim()) return; + setBusy(true); + setError(null); + try { + await api(`/api/one-on-ones/${active.id}/items`, { + method: "POST", + body: JSON.stringify({ questionText: manualQuestion.trim(), source: "manual" }), + }); + setManualQuestion(""); + if (active.status === "planned") { + await api(`/api/one-on-ones/${active.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "in_progress" }), + }); + } + onCaptureMsg?.("Question added"); + await refreshActive(); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not add question"); + } finally { + setBusy(false); + } + } + + async function saveAnswer(item: OneOnOneItemDTO, answerText: string) { + setError(null); + try { + await api(`/api/one-on-one-items/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ answerText }), + }); + await refreshActive(); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not save answer"); + } + } + + async function removeItem(itemId: string) { + setBusy(true); + try { + await api(`/api/one-on-one-items/${itemId}`, { method: "DELETE" }); + await refreshActive(); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not delete question"); + } finally { + setBusy(false); + } + } + + async function suggestWithAi() { + if (!active) return; + const ok = await estimateThenConfirm( + "one_on_one_questions", + { personId, cycleId: cycleId || undefined }, + "Suggest 1:1 questions from dossier evidence", + ); + if (!ok) return; + setBusy(true); + setError(null); + try { + const res = await api<{ + questions: string[]; + added: OneOnOneItemDTO[]; + session: OneOnOneSessionDTO; + }>(`/api/one-on-ones/${active.id}/suggest-questions`, { + method: "POST", + body: JSON.stringify({ + cycleId: cycleId || undefined, + count: 6, + addToSession: true, + }), + }); + onCaptureMsg?.(`Added ${res.added.length} AI-suggested question${res.added.length === 1 ? "" : "s"}`); + await load(res.session.id); + } catch (e) { + setError(e instanceof Error ? e.message : "AI suggest failed"); + } finally { + setBusy(false); + } + } + + return ( +
void suggestWithAi()} + > + Suggest questions with AI + + ) : undefined + } + > + {error &&

{error}

} + +
+
+ + setOccurredAt(e.target.value)} /> +
+
+ + setTitle(e.target.value)} + placeholder={`1:1 — ${personName}`} + /> +
+ +
+ + {sessions.length === 0 ? ( +

+ No 1:1 sessions yet. Start one to capture an agenda — AI is optional. +

+ ) : ( + <> +
+ + +
+ + {active && ( +
+
+ + {active.status} + + + + +
+ +
+ +