CoDEVX / components /ChatPanel.tsx
CodexMacTiger
feat: live package-scoped chat and thinking logs
837e3ac
"use client";
import {
useEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import type {
ChatMessage,
ConnectionStatus,
WorkPackage,
} from "@/lib/work-package-types";
import type { LlmConfig } from "@/lib/llm-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
applyComposerSuggestion,
getComposerSuggestions,
type ComposerSuggestion,
} from "@/lib/composer-autocomplete";
import {
AlertCircle,
CheckCircle2,
LoaderCircle,
Send,
Settings2,
Sparkles,
X,
} from "lucide-react";
function statusTone(status: ConnectionStatus) {
if (status === "connected") return "text-emerald-700";
if (status === "checking") return "text-amber-700";
if (status === "error") return "text-rose-700";
return "text-muted-foreground";
}
function StatusIcon(props: { status: ConnectionStatus }) {
const { status } = props;
if (status === "connected") return <CheckCircle2 className="h-3.5 w-3.5" />;
if (status === "checking") {
return <LoaderCircle className="h-3.5 w-3.5 animate-spin" />;
}
if (status === "error") return <AlertCircle className="h-3.5 w-3.5" />;
return <Settings2 className="h-3.5 w-3.5" />;
}
export function ChatPanel(props: {
messages: ChatMessage[];
draft: string;
setDraft: (v: string) => void;
busy: boolean;
productTitle: string;
currentActivity: string;
workPackages: WorkPackage[];
selectedWorkPackageId?: string;
onSelectWorkPackage: (id: string) => void;
llmConfig: LlmConfig;
setLlmConfig: Dispatch<SetStateAction<LlmConfig>>;
isMockMode: boolean;
connectionStatus: ConnectionStatus;
connectionMessage: string;
canTestConnection: boolean;
onTestConnection: () => void;
onSend: (text: string) => void | Promise<void>;
initialSettingsOpen?: boolean;
}) {
const {
messages,
draft,
setDraft,
busy,
productTitle,
currentActivity,
workPackages,
selectedWorkPackageId,
onSelectWorkPackage,
llmConfig,
setLlmConfig,
isMockMode,
connectionStatus,
connectionMessage,
canTestConnection,
onTestConnection,
onSend,
initialSettingsOpen,
} = props;
const bottomRef = useRef<HTMLDivElement | null>(null);
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen ?? false);
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
const showSettings = settingsOpen;
const selectedWorkPackage = workPackages.find(
(workPackage) => workPackage.id === selectedWorkPackageId,
);
const suggestions = getComposerSuggestions(draft, workPackages);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages.length]);
function applySuggestion(suggestion: ComposerSuggestion) {
setDraft(
applyComposerSuggestion({
draft,
suggestion,
selectedWorkPackage,
}),
);
setActiveSuggestionIndex(0);
if (suggestion.kind === "package") {
onSelectWorkPackage(suggestion.id);
}
}
return (
<div className="flex h-full min-h-0 flex-col bg-muted/18">
<div className="px-4 py-3 md:px-5">
<div className="text-sm font-semibold">{productTitle}</div>
<div className="text-xs text-muted-foreground">
Use <span className="font-mono">@SRS ask</span>,{" "}
<span className="font-mono">@Design FMEA execute</span>,{" "}
<span className="font-mono">/plan</span>, or paste a product idea to
auto-run the whole board.
</div>
</div>
<div className="px-4 pb-2 md:px-5">
<div className="rounded-2xl bg-background/82 px-3 py-2 shadow-sm ring-1 ring-black/5">
<div className="flex items-center gap-2 text-[11px] font-medium">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span>{busy ? currentActivity : "Ready. You can describe a product or target a package directly."}</span>
</div>
<div className="mt-1 text-[11px] leading-5 text-muted-foreground">
Tip: type <span className="font-mono">@</span> for packages or{" "}
<span className="font-mono">/</span> for ask, plan, change, and
execute shortcuts.
</div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 px-4 py-1 md:px-5">
{messages.map((message) => (
<div
key={message.id}
className={[
"max-w-[52rem] whitespace-pre-wrap rounded-xl px-3 py-2 text-sm leading-6 shadow-sm ring-1 ring-black/5",
message.role === "user"
? "ml-auto bg-secondary/90"
: "mr-auto bg-background",
].join(" ")}
>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{message.role === "user" ? "You" : productTitle}
</div>
<div>{message.content}</div>
</div>
))}
<div ref={bottomRef} />
</div>
</ScrollArea>
<div className="relative p-4 pt-3 md:p-5 md:pt-3">
{suggestions.length ? (
<div className="absolute inset-x-4 bottom-[calc(100%+0.75rem)] z-20 rounded-2xl bg-background/96 p-2 shadow-lg ring-1 ring-black/10 md:inset-x-5">
<div className="space-y-1">
{suggestions.map((suggestion, index) => {
const active = index === activeSuggestionIndex;
return (
<button
key={
suggestion.kind === "package"
? suggestion.id
: suggestion.mode
}
type="button"
className={[
"flex w-full items-start justify-between gap-3 rounded-xl px-3 py-2 text-left transition-colors",
active ? "bg-muted" : "hover:bg-muted/70",
].join(" ")}
onClick={() => applySuggestion(suggestion)}
>
<div className="min-w-0">
<div className="text-sm font-medium">
{suggestion.kind === "package"
? `@${suggestion.shortName}`
: suggestion.label}
</div>
<div className="text-xs text-muted-foreground">
{suggestion.kind === "package"
? suggestion.title
: suggestion.description}
</div>
</div>
<div className="pt-0.5 text-[11px] text-muted-foreground">
{suggestion.kind === "package" ? "Package" : "Action"}
</div>
</button>
);
})}
</div>
</div>
) : null}
{showSettings ? (
<div className="absolute inset-x-4 bottom-[calc(100%+0.75rem)] z-20 rounded-2xl bg-background px-3 py-3 shadow-lg ring-1 ring-black/10 md:inset-x-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold">Model Settings</div>
<div className="text-[11px] text-muted-foreground">
Stored locally in this browser. Live mode turns on after the connection test succeeds.
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-xl"
onClick={() => setSettingsOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div
className={[
"mt-3 flex items-center gap-2 rounded-xl bg-muted/28 px-2.5 py-2 text-[11px]",
statusTone(connectionStatus),
].join(" ")}
>
<StatusIcon status={connectionStatus} />
<span>{connectionMessage}</span>
</div>
<div className="mt-3 space-y-2.5">
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
API Key
</div>
<Input
type="password"
value={llmConfig.apiKey}
placeholder="Paste your API key"
className="h-9 rounded-xl bg-background/92"
onChange={(e) =>
setLlmConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
/>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
Base URL
</div>
<Input
value={llmConfig.baseUrl}
className="h-9 rounded-xl bg-background/92"
onChange={(e) =>
setLlmConfig((prev) => ({
...prev,
baseUrl: e.target.value,
}))
}
/>
</div>
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
Model
</div>
<Input
value={llmConfig.model}
className="h-9 rounded-xl bg-background/92"
onChange={(e) =>
setLlmConfig((prev) => ({
...prev,
model: e.target.value,
}))
}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
className="h-8 rounded-xl px-3 text-[11px]"
disabled={!canTestConnection || connectionStatus === "checking"}
onClick={onTestConnection}
>
<StatusIcon status={connectionStatus} />
<span className="ml-1">
{connectionStatus === "checking"
? "Testing connection..."
: "Test connection"}
</span>
</Button>
</div>
</div>
</div>
) : null}
<form
className="flex items-end gap-2.5"
onSubmit={(event) => {
event.preventDefault();
onSend(draft);
}}
>
<Textarea
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setActiveSuggestionIndex(0);
}}
placeholder="Type a product idea, @Package..., or /ask /plan /change /execute ..."
className="min-h-[148px] resize-none rounded-[24px] bg-background/96 px-4 py-3.5 text-sm leading-6 shadow-sm ring-1 ring-black/5"
onKeyDown={(event) => {
if (suggestions.length && event.key === "ArrowDown") {
event.preventDefault();
setActiveSuggestionIndex((current) =>
Math.min(current + 1, suggestions.length - 1),
);
return;
}
if (suggestions.length && event.key === "ArrowUp") {
event.preventDefault();
setActiveSuggestionIndex((current) => Math.max(current - 1, 0));
return;
}
if (suggestions.length && event.key === "Escape") {
event.preventDefault();
setDraft(draft.replace(/(?:^|\s)([@/][A-Za-z\s-]*)$/, ""));
return;
}
if (
suggestions.length &&
event.key === "Enter" &&
!event.metaKey &&
!event.ctrlKey
) {
event.preventDefault();
applySuggestion(suggestions[activeSuggestionIndex] ?? suggestions[0]);
return;
}
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
onSend(draft);
}
}}
/>
<Button
type="submit"
disabled={busy || !draft.trim()}
size="icon"
className="h-12 w-12 rounded-2xl"
>
<Send className="h-4 w-4" />
</Button>
</form>
<div className="mt-2 flex items-center justify-between gap-3">
<div className="text-xs text-muted-foreground">
Press <span className="font-mono">Ctrl</span>+
<span className="font-mono">Enter</span> to send. Use{" "}
<span className="font-mono">@</span> or{" "}
<span className="font-mono">/</span> for suggestions.
</div>
<Button
type="button"
variant="ghost"
className="h-8 rounded-xl px-2.5 text-[11px]"
onClick={() => setSettingsOpen((value) => !value)}
>
<StatusIcon status={connectionStatus} />
<span className="ml-1">
{isMockMode ? "Model Settings" : "Live Settings"}
</span>
</Button>
</div>
</div>
</div>
);
}