CoDEVX / components /AppShell.tsx
CodexMacTiger
feat: live package-scoped chat and thinking logs
837e3ac
"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<ViewMode>("chat");
const [projectTitle, setProjectTitle] = useState("Agentic PM Demo");
const [workPackages, setWorkPackages] = useState<WorkPackage[]>(() =>
hydrateWorkPackages(seedWorkPackages as WorkPackage[]),
);
const [selectedWorkPackageId, setSelectedWorkPackageId] = useState<
string | undefined
>(() => (seedWorkPackages as WorkPackage[])[0]?.id);
const [detailOpen, setDetailOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>(() => {
return [
{
id: "welcome-message",
role: "assistant",
content: welcomeMessage(false),
createdAt: nowIso(),
},
];
});
const [agentLogs, setAgentLogs] = useState<AgentLogEntry[]>(() => [
{
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<ConnectionStatus>("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<LlmConfig>(() =>
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<LlmConfig>;
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 (
<div className="flex h-[100dvh] flex-col bg-muted/40">
<div className="bg-background/72 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1760px] px-4 py-3 md:px-5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">
{projectTitle}
</div>
<div className="truncate text-xs text-muted-foreground">
{busy ? currentActivity : "Chat + automated IoT work packages"}
</div>
</div>
<div className="shrink-0 md:hidden">
<Tabs value={view} onValueChange={(v) => setView(v as ViewMode)}>
<TabsList>
<TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="board">Board</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="hidden text-xs text-muted-foreground md:block">
{isMockMode ? "Mock mode" : `Live · ${llmConfig.model}`}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden px-3 pb-3 md:px-4 md:pb-4">
<div className="mx-auto grid h-full w-full max-w-[1760px] grid-cols-1 gap-3 md:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
<div
className={[
"h-full min-h-0 overflow-hidden rounded-[22px] bg-background/82 shadow-sm backdrop-blur-sm",
view === "board" ? "hidden md:block" : "block",
].join(" ")}
>
<ChatPanel
messages={messages}
draft={draft}
setDraft={setDraft}
busy={busy}
productTitle={projectTitle}
currentActivity={currentActivity}
workPackages={workPackages}
selectedWorkPackageId={selectedWorkPackageId}
onSelectWorkPackage={(id) => {
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}
/>
</div>
<div
className={[
"h-full min-h-0 overflow-hidden rounded-[22px] bg-background/82 shadow-sm backdrop-blur-sm",
view === "chat" ? "hidden md:block" : "block",
].join(" ")}
>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 p-2">
<WorkPackageBoard
workPackages={workPackages}
activeWorkPackageId={activeWorkPackageId}
selectedWorkPackageId={selectedWorkPackageId}
onSelect={(id) => {
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");
}}
/>
</div>
<div className="h-[22dvh] min-h-[150px] px-2 pb-2">
<AgentLogPanel logs={agentLogs} busy={busy} />
</div>
</div>
</div>
</div>
</div>
</div>
);
}