Spaces:
Running
Running
| "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> | |
| ); | |
| } | |