"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentLogEntry, ChatMessage, ConnectionStatus, ResolvedChatContext, WorkPackage, } from "@/lib/work-package-types"; import type { LlmConfig } from "@/lib/llm-config"; import type { ModelTrace } from "@/lib/chat-trace"; import { parseCommand } from "@/lib/command-parser"; import { applyBoardAction, type ChatResponse } from "@/lib/board-actions"; import { runMockAgent } from "@/lib/mock-agent"; import { hydrateWorkPackages } from "@/lib/work-package-specs"; import { buildAutomationInstruction } from "@/lib/automation-agent"; import { extractProductTitle } from "@/lib/product-title"; import { resolveChatRequestContext } from "@/lib/chat-request-context"; import { buildResolvedContext, buildThinkingSummary, } from "@/lib/chat-request-context"; import { buildThinkingLogEntries } from "@/lib/agent-thinking-log"; import { AgentLogPanel } from "@/components/AgentLogPanel"; import { ChatPanel } from "@/components/ChatPanel"; import { WorkPackageBoard } from "@/components/WorkPackageBoard"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { isLlmConfigReady, LLM_SETTINGS_STORAGE_KEY, normalizeLlmConfig, } from "@/lib/llm-config"; import seedWorkPackages from "@/agentic_pm_demo_codex_plans/data/work-packages.seed.json"; type ViewMode = "chat" | "board"; function nowIso() { return new Date().toISOString(); } function newId(prefix: string) { return `${prefix}-${crypto.randomUUID()}`; } function welcomeMessage(live: boolean) { return live ? "Live mode is connected. Type a product idea to auto-run the full board, or use a work package command like:\n`@SRS ask ...` `@Design FMEA execute ...`" : "Mock mode is active until you add an API key in Model Settings below.\n\nType a product idea to auto-run the full board, or use a work package command like:\n`@SRS ask ...` `@Design FMEA execute ...`"; } export function AppShell() { const [view, setView] = useState("chat"); const [projectTitle, setProjectTitle] = useState("Agentic PM Demo"); const [workPackages, setWorkPackages] = useState(() => hydrateWorkPackages(seedWorkPackages as WorkPackage[]), ); const [selectedWorkPackageId, setSelectedWorkPackageId] = useState< string | undefined >(() => (seedWorkPackages as WorkPackage[])[0]?.id); const [detailOpen, setDetailOpen] = useState(false); const [messages, setMessages] = useState(() => { return [ { id: "welcome-message", role: "assistant", content: welcomeMessage(false), createdAt: nowIso(), }, ]; }); const [agentLogs, setAgentLogs] = useState(() => [ { id: newId("log"), title: "Workspace ready", detail: "Workspace ready. Add an API key below for live automation, or stay in mock mode.", level: "info", createdAt: nowIso(), }, ]); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false); const [connectionStatus, setConnectionStatus] = useState("idle"); const [connectionMessage, setConnectionMessage] = useState( "Add your model settings to enable live automation.", ); const [activeWorkPackageId, setActiveWorkPackageId] = useState< string | undefined >(); const [currentActivity, setCurrentActivity] = useState( "Ready for a product idea or a package command.", ); const [llmConfig, setLlmConfig] = useState(() => normalizeLlmConfig(), ); const lastConnectionLogKeyRef = useRef(""); const addLog = useCallback( (title: string, detail: string, level: AgentLogEntry["level"]) => { setAgentLogs((prev) => [ ...prev, { id: newId("log"), title, detail, level, createdAt: nowIso(), }, ]); }, [], ); const runConnectionCheck = useCallback(async (config: LlmConfig) => { if (!isLlmConfigReady(config)) { setConnectionStatus("idle"); setConnectionMessage("Add API key, base URL, and model to enable live automation."); return; } setConnectionStatus("checking"); setConnectionMessage("Testing model connection..."); try { const res = await fetch("/api/test-connection", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ llmConfig: config }), }); const data = (await res.json()) as { ok?: boolean; status?: ConnectionStatus; message?: string; model?: string; }; if (data.ok) { setConnectionStatus("connected"); setConnectionMessage(data.message || "Connection verified."); const logKey = `${config.baseUrl}|${config.model}|connected`; if (lastConnectionLogKeyRef.current !== logKey) { lastConnectionLogKeyRef.current = logKey; addLog( "Model connected", `${config.model} is ready for live automation.`, "success", ); } return; } setConnectionStatus(data.status === "error" ? "error" : "idle"); setConnectionMessage(data.message || "Connection test did not complete."); const logKey = `${config.baseUrl}|${config.model}|${data.message}`; if (data.status === "error" && lastConnectionLogKeyRef.current !== logKey) { lastConnectionLogKeyRef.current = logKey; addLog( "Model connection failed", data.message || "Connection test failed.", "warn", ); } } catch (error) { setConnectionStatus("error"); setConnectionMessage(String(error)); } }, [addLog]); useEffect(() => { try { const raw = window.localStorage.getItem(LLM_SETTINGS_STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as Partial; setLlmConfig(normalizeLlmConfig(parsed)); } } catch { // ignore malformed local storage } finally { setSettingsLoaded(true); } }, []); useEffect(() => { if (!settingsLoaded) return; try { window.localStorage.setItem( LLM_SETTINGS_STORAGE_KEY, JSON.stringify(llmConfig), ); } catch { // ignore persistence failures } }, [llmConfig, runConnectionCheck, settingsLoaded]); useEffect(() => { if (typeof document === "undefined") return; document.title = projectTitle === "Agentic PM Demo" ? projectTitle : `${projectTitle} · Agentic PM Demo`; }, [projectTitle]); useEffect(() => { setMessages((prev) => { if (!prev.length || prev[0]?.id !== "welcome-message") return prev; const next = [...prev]; next[0] = { ...next[0], content: welcomeMessage(connectionStatus === "connected"), }; return next; }); }, [connectionStatus]); useEffect(() => { if (!settingsLoaded) return; if (!isLlmConfigReady(llmConfig)) { setConnectionStatus("idle"); setConnectionMessage("Add API key, base URL, and model to enable live automation."); return; } const timeout = window.setTimeout(async () => { await runConnectionCheck(llmConfig); }, 700); return () => { window.clearTimeout(timeout); }; }, [llmConfig, runConnectionCheck, settingsLoaded]); const isMockMode = useMemo( () => connectionStatus !== "connected", [connectionStatus], ); function logModelTrace(trace: ModelTrace | undefined) { if (!trace) return; if (trace.requestPreview) { addLog( "Model request", `${trace.model ?? "unknown"}${trace.endpoint ? ` @ ${trace.endpoint}` : ""} :: ${trace.requestPreview}`, trace.provider === "live" ? "info" : "warn", ); } if (trace.responsePreview) { addLog( "Model response", trace.responsePreview, trace.provider === "live" ? "success" : "info", ); } } function logThinkingSummary(lines: string[] | undefined, knownLines: string[] = []) { const known = new Set(knownLines); for (const entry of buildThinkingLogEntries({ thinkingSummary: (lines ?? []).filter((line) => !known.has(line)), })) { addLog(entry.title, entry.detail, entry.level); } } async function requestChatResponse(args: { nextMessages: ChatMessage[]; boardSnapshot: WorkPackage[]; selectedId?: string; requestContext: ResolvedChatContext; parsedCommand: { referencedPackageName?: string; mode?: "ask" | "plan" | "change" | "execute"; instruction: string; }; }) { const res = await fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages: args.nextMessages.map((message) => ({ role: message.role, content: message.content, })), workPackages: args.boardSnapshot, selectedWorkPackageId: args.selectedId ?? null, requestContext: args.requestContext, parsedCommand: args.parsedCommand, llmConfig, }), }); if (!res.ok) { throw new Error(`Request failed with status ${res.status}.`); } return (await res.json()) as ChatResponse; } async function automateBoardFromProductIdea(args: { productIdea: string; nextMessages: ChatMessage[]; fallbackToMock?: boolean; }) { const hydrated = hydrateWorkPackages(workPackages, args.productIdea); let boardState = hydrated; const completedShortNames: string[] = []; const newTitle = extractProductTitle(args.productIdea); setProjectTitle(newTitle); setWorkPackages(hydrated); setDetailOpen(false); setActiveWorkPackageId(undefined); setCurrentActivity(`Preparing board for ${newTitle}.`); addLog( "Board hydrated", `Prepared ${hydrated.length} work packages from the product idea.`, "info", ); for (const workPackage of hydrated) { setActiveWorkPackageId(workPackage.id); setCurrentActivity(`Running ${workPackage.shortName} for ${newTitle}.`); addLog( "Package running", `${workPackage.shortName} is running with ${ isMockMode ? "mock mode" : llmConfig.model }.`, "info", ); const response = args.fallbackToMock ? runMockAgent({ messages: args.nextMessages, workPackages: boardState, selectedWorkPackageId: workPackage.id, parsedCommand: { referencedPackageName: workPackage.title, mode: "execute", instruction: buildAutomationInstruction(workPackage), }, }) : await requestChatResponse({ nextMessages: args.nextMessages, boardSnapshot: boardState, selectedId: workPackage.id, requestContext: buildResolvedContext({ parsedCommand: { referencedPackageName: workPackage.title, mode: "execute", instruction: buildAutomationInstruction(workPackage), }, shouldAutomateBoard: false, selectedWorkPackage: workPackage, provider: "live", }), parsedCommand: { referencedPackageName: workPackage.title, mode: "execute", instruction: buildAutomationInstruction(workPackage), }, }); if (response.boardAction) { boardState = applyBoardAction(boardState, response.boardAction); setWorkPackages(boardState); } logModelTrace(response.trace); completedShortNames.push(workPackage.shortName); addLog( "Package completed", `${workPackage.shortName} completed.`, "success", ); } setActiveWorkPackageId(undefined); setCurrentActivity(`Completed board automation for ${newTitle}.`); setMessages((prev) => [ ...prev, { id: newId("m"), role: "assistant", content: isMockMode ? `Completed all ${completedShortNames.length} work packages in mock mode.\n\nGenerated outputs: ${completedShortNames.join(", ")}` : `Completed all ${completedShortNames.length} work packages with ${llmConfig.model}.\n\nGenerated outputs: ${completedShortNames.join(", ")}`, createdAt: nowIso(), }, ]); addLog( "Board automation finished", `Generated outputs for ${completedShortNames.join(", ")}.`, "success", ); } async function handleChatSend(rawText: string) { const text = rawText.trim(); if (!text) return; const userMsg: ChatMessage = { id: newId("m"), role: "user", content: text, createdAt: nowIso(), }; const nextMessages = [...messages, userMsg]; const parsed = parseCommand(text, workPackages); const selectedWorkPackage = workPackages.find( (workPackage) => workPackage.id === selectedWorkPackageId, ); const contextWorkPackage = workPackages.find( (workPackage) => workPackage.id === parsed.matchedWorkPackageId, ) ?? selectedWorkPackage; const resolvedContext = resolveChatRequestContext({ parsed: parsed.parsed, detailOpen, selectedWorkPackage: contextWorkPackage, }); const effectiveParsedCommand = resolvedContext.parsedCommand; const requestContext = buildResolvedContext({ parsedCommand: effectiveParsedCommand, shouldAutomateBoard: resolvedContext.shouldAutomateBoard, selectedWorkPackage: contextWorkPackage, provider: isMockMode ? "mock" : "live", }); const requestThinking = buildThinkingSummary({ context: requestContext, model: isMockMode ? undefined : llmConfig.model, }); setMessages(nextMessages); setDraft(""); if (parsed.matchedWorkPackageId) { setSelectedWorkPackageId(parsed.matchedWorkPackageId); setDetailOpen(true); addLog( "Package selected", `Focused ${parsed.matchedWorkPackageTitle ?? parsed.parsed.referencedPackageName ?? "package"} from command context.`, "info", ); } else if (resolvedContext.source === "board-automation") { setDetailOpen(false); } if (parsed.error) { const suggestionLine = parsed.suggestions && parsed.suggestions.length ? `\n\nDid you mean:\n${parsed.suggestions .map((s) => `- ${s.shortName} (${s.title})`) .join("\n")}` : ""; setMessages((prev) => [ ...prev, { id: newId("m"), role: "assistant", content: `${parsed.error}${suggestionLine}`, createdAt: nowIso(), }, ]); addLog("Command parse warning", parsed.error, "warn"); return; } // Mock-first: call local mock agent if the command is @... OR if the user is just brainstorming. // Once the /api/chat route is present, we’ll prefer it for consistent behavior. setBusy(true); setCurrentActivity( effectiveParsedCommand.mode ? `Running ${effectiveParsedCommand.mode} on ${effectiveParsedCommand.referencedPackageName ?? "selected package"}.` : "Processing product idea and preparing the board.", ); addLog( "Agent started", effectiveParsedCommand.mode ? `Running ${effectiveParsedCommand.mode} on ${effectiveParsedCommand.referencedPackageName ?? "board context"}.` : "Processing free-form product planning request.", "info", ); logThinkingSummary(requestThinking); if (resolvedContext.source === "selected-package-context") { addLog( "Detail question routed", `Treating the plain-text prompt as an ask request for ${contextWorkPackage?.title ?? "the selected work package"}.`, "info", ); } else if (resolvedContext.source === "slash-command") { addLog( "Slash command routed", `Applied /${effectiveParsedCommand.mode ?? "ask"} to ${contextWorkPackage?.title ?? "the selected work package"}.`, "info", ); } try { if (resolvedContext.shouldAutomateBoard) { await automateBoardFromProductIdea({ productIdea: text, nextMessages, }); return; } // Call server route first (it will fall back to mock mode automatically if no key). if (effectiveParsedCommand.mode === "execute") { setActiveWorkPackageId( parsed.matchedWorkPackageId ?? selectedWorkPackageId, ); } const response = await requestChatResponse({ nextMessages, boardSnapshot: workPackages, selectedId: parsed.matchedWorkPackageId ?? selectedWorkPackageId, requestContext, parsedCommand: effectiveParsedCommand, }); logThinkingSummary(response.thinkingSummary, requestThinking); logModelTrace(response.trace); if (response?.boardAction) { setWorkPackages((prev) => applyBoardAction(prev, response?.boardAction)); if (response.boardAction.type === "replace_all") { setDetailOpen(false); addLog( "Board refreshed", "Hydrated the work package board from the product idea.", "success", ); } else if (response.boardAction.type !== "none") { addLog( "Board updated", `Applied ${response.boardAction.type} to ${effectiveParsedCommand.referencedPackageName ?? "work package"}.`, "success", ); } } setMessages((prev) => [ ...prev, { id: newId("m"), role: "assistant", content: response?.assistantMessage ?? "No response.", createdAt: nowIso(), }, ]); addLog( "Agent finished", response?.assistantMessage ?? "No response.", "success", ); } catch (err) { if (resolvedContext.shouldAutomateBoard) { addLog( "Automation fallback", "Live automation failed, continuing in mock mode.", "warn", ); await automateBoardFromProductIdea({ productIdea: text, nextMessages, fallbackToMock: true, }); return; } const fallbackResponse = runMockAgent({ messages: nextMessages, workPackages, selectedWorkPackageId: parsed.matchedWorkPackageId ?? selectedWorkPackageId, parsedCommand: effectiveParsedCommand, }); if (fallbackResponse.boardAction) { setWorkPackages((prev) => applyBoardAction(prev, fallbackResponse.boardAction), ); } setMessages((prev) => [ ...prev, { id: newId("m"), role: "assistant", content: fallbackResponse.assistantMessage || `Network error. (Mock mode)\n\n${String(err)}`, createdAt: nowIso(), }, ]); addLog( "Fallback path", "Live request failed locally; switched to the mock response path.", "warn", ); addLog("Agent error", String(err), "error"); } finally { setActiveWorkPackageId(undefined); setCurrentActivity("Ready for the next step."); setBusy(false); } } return (
{projectTitle}
{busy ? currentActivity : "Chat + automated IoT work packages"}
setView(v as ViewMode)}> Chat Board
{isMockMode ? "Mock mode" : `Live · ${llmConfig.model}`}
{ setSelectedWorkPackageId(id); }} llmConfig={llmConfig} setLlmConfig={setLlmConfig} isMockMode={isMockMode} connectionStatus={connectionStatus} connectionMessage={connectionMessage} canTestConnection={isLlmConfigReady(llmConfig)} onTestConnection={() => { addLog("Connection test requested", "Checking the configured model settings.", "info"); void runConnectionCheck(llmConfig); }} onSend={handleChatSend} />
{ setSelectedWorkPackageId(id); setDetailOpen(true); const selected = workPackages.find((wp) => wp.id === id); addLog( "Detail opened", `Opened ${selected?.title ?? "work package"} detail view.`, "info", ); }} onPrefill={(s) => { setView("chat"); setDraft(s); addLog("Command prepared", `Prefilled chat input with: ${s}`, "info"); }} detailOpen={detailOpen} onBackToBoard={() => { setDetailOpen(false); addLog("Board view restored", "Returned to work package card board.", "info"); }} />
); }