import { useEffect, useRef, useState, useCallback } from "preact/hooks"; import type { ThreadData, LinearIssue, SessionEntry } from "../../lib/types"; import type { SpeechRecognizer } from "@palace/sdk/speech"; import { useSpeechRecognizer } from "@palace/sdk/speech/preact"; import { useSessionLog } from "../../lib/use-session-log"; import { fmtSessTs, typeClass, formatTypeLabel } from "../../lib/format"; import { sendChatStream, attachThread } from "../../lib/api"; import { renderMarkdown } from "../../lib/render-md"; import { X, Mic, SendHorizontal, ChevronRight } from "lucide-preact"; interface ThreadDetailProps { thread: ThreadData; linearIssues: LinearIssue[]; onAttach: (threadId: string, issueId: string) => void; onChangeStatus: (newStatus: string) => void; onDelete?: () => void; recognizer: SpeechRecognizer; onChatSent: () => void; } interface DraftDetailProps { recognizer: SpeechRecognizer; linearIssues: LinearIssue[]; onSent: (threadId: string) => void; onClose: () => void; } type ChatMsg = { role: "user" | "ace"; content: string }; export function ThreadDetail({ thread, linearIssues, onAttach, onChangeStatus, onDelete, recognizer, onChatSent }: ThreadDetailProps) { const isActive = thread.status === "processing"; const { entries, loading } = useSessionLog(thread.sessionFile, true, isActive); const logRef = useRef(null); useEffect(() => { if (logRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [entries]); return (
{thread.sessionFile && loading && entries.length === 0 && (
Loading session log...
)} {thread.sessionFile && !loading && entries.length === 0 && (
No session log entries yet.
)} {thread.sessionFile && entries.map((e, i) => ( ))} {!thread.sessionFile && thread.logLines.map((l, i) => (
{l}
))}
{thread.channel === "code" && ( )}
); } export function DraftDetail({ recognizer, linearIssues, onSent, onClose }: DraftDetailProps) { const inputRef = useRef(null); const titleRef = useRef(null); const logRef = useRef(null); const [busy, setBusy] = useState(false); const [history, setHistory] = useState([]); const [streamingText, setStreamingText] = useState(null); const [status, setStatus] = useState(null); const [attachedIssue, setAttachedIssue] = useState(null); const [attachOpen, setAttachOpen] = useState(false); const [activeThreadId, setActiveThreadId] = useState(null); const onTranscribed = useCallback((text: string) => { if (inputRef.current) { inputRef.current.value += (inputRef.current.value ? " " : "") + text; inputRef.current.focus(); } }, []); const { recording, transcribing, toggle } = useSpeechRecognizer(recognizer, { onResult: onTranscribed, onStatus: setStatus }); useEffect(() => { if (logRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [history, streamingText]); const send = useCallback(async () => { const msg = inputRef.current?.value.trim(); if (!msg || busy) return; inputRef.current!.value = ""; const title = !activeThreadId ? (titleRef.current?.value.trim() || undefined) : undefined; setHistory(prev => [...prev, { role: "user", content: msg }]); setBusy(true); setStreamingText(""); let full = ""; try { const result = await sendChatStream(activeThreadId, msg, (chunk) => { full += chunk; setStreamingText(full); }, title ? { title } : undefined); if (!activeThreadId && attachedIssue) { await attachThread(result.threadId, attachedIssue).catch(() => {}); } setHistory(prev => [...prev, { role: "ace", content: full }]); setStreamingText(null); setActiveThreadId(result.threadId); setBusy(false); onSent(result.threadId); } catch (e: any) { setHistory(prev => [...prev, { role: "ace", content: `Error: ${(e as Error).message}` }]); setStreamingText(null); setBusy(false); } }, [busy, onSent, attachedIssue, activeThreadId]); const micClass = `chat-reply-mic${recording ? " recording" : ""}${transcribing ? " transcribing" : ""}`; return (
{history.length === 0 && streamingText === null && (
Type a message to start a conversation with Ace.
)} {history.map((msg, i) => ( msg.role === "user" ? (
{msg.content}
) : (
) ))} {streamingText !== null && (
Thinking\u2026' }} />
)} {status &&
{status}
}
{ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} disabled={busy} autoFocus />
); } function ChatInput({ threadId, recognizer, onSent }: { threadId: string; recognizer: SpeechRecognizer; onSent: () => void }) { const inputRef = useRef(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const onTranscribed = useCallback((text: string) => { if (inputRef.current) { inputRef.current.value += (inputRef.current.value ? " " : "") + text; inputRef.current.focus(); } }, []); const { recording, transcribing, toggle } = useSpeechRecognizer(recognizer, { onResult: onTranscribed, onStatus: setStatus }); const send = useCallback(async () => { const msg = inputRef.current?.value.trim(); if (!msg || busy) return; inputRef.current!.value = ""; setBusy(true); try { await sendChatStream(threadId, msg, () => {}); onSent(); } catch {} setBusy(false); }, [threadId, busy, onSent]); const micClass = `chat-reply-mic${recording ? " recording" : ""}${transcribing ? " transcribing" : ""}`; return (
{ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} disabled={busy} /> {status &&
{status}
} {busy && !status &&
Sending...
}
); } const COLLAPSIBLE = new Set(["TOOL RESULT", "SESSION START"]); function isCollapsible(type: string): boolean { return COLLAPSIBLE.has(type) || /^TOOL CALL/.test(type); } function LogEntry({ entry }: { entry: SessionEntry }) { const [open, setOpen] = useState(false); if (entry.type === "USER") { return (
{entry.content}
{fmtSessTs(entry.ts)}
); } if (entry.type === "TEXT") { return (
{fmtSessTs(entry.ts)}
); } const collapsed = isCollapsible(entry.type); const showContent = /^RESULT/.test(entry.type); return (
setOpen(!open) : undefined} > {collapsed && } {formatTypeLabel(entry.type)} {fmtSessTs(entry.ts)} {collapsed && !open && entry.content && ( {entry.content.slice(0, 60)}{entry.content.length > 60 ? "..." : ""} )}
{showContent && (
{entry.content}
)} {collapsed && open && (
{entry.content}
)}
); }