import "../../styles/palacecode.css"; import { useState, useEffect, useMemo, useCallback, useRef } from "preact/hooks"; import type { LinearIssue, ThreadData } from "../../lib/types"; import type { Attachment } from "../../lib/auto-attach"; import { useThreads } from "../../lib/use-threads"; import { autoAttach } from "../../lib/auto-attach"; import { attachThread as apiAttach, updateThreadStatus, deleteThread as apiDelete, sendChatStream } from "../../lib/api"; import { statusOf, applyStale, fmtDur } from "../../lib/format"; import { SpeechRecognizer } from "@palace/sdk/speech"; import { useGamepad } from "../../lib/use-gamepad"; import { Plus, Gamepad2 } from "lucide-preact"; import ThreadCard from "./ThreadCard"; import { ThreadDetail, DraftDetail } from "./DetailPanel"; const WINDOWS = [ { label: "1h", ms: 1 * 60 * 60 * 1000 }, { label: "6h", ms: 6 * 60 * 60 * 1000 }, { label: "12h", ms: 12 * 60 * 60 * 1000 }, { label: "1d", ms: 24 * 60 * 60 * 1000 }, { label: "3d", ms: 3 * 24 * 60 * 60 * 1000 }, { label: "7d", ms: 7 * 24 * 60 * 60 * 1000 }, ]; export default function Dashboard() { const [windowIdx, setWindowIdx] = useState(3); // default: 1d const [showWindowPicker, setShowWindowPicker] = useState(false); const [showArchived, setShowArchived] = useState(false); const { threads, refresh } = useThreads(3000, WINDOWS[windowIdx].ms); const [linearIssues, setLinearIssues] = useState([]); useEffect(() => { fetch("/code/api/linear-issues") .then((r) => r.ok ? r.json() : []) .then((data) => Array.isArray(data) && setLinearIssues(data)) .catch(() => {}); }, []); const [attachments, setAttachments] = useState>({}); useEffect(() => { fetch("/code/api/attach-thread") .then((r) => r.ok ? r.json() : []) .then((data: { threadId: string; linearIssue: string }[]) => { if (!Array.isArray(data)) return; const map: Record = {}; for (const a of data) if (a.threadId && a.linearIssue) map[a.threadId] = { linearIssue: a.linearIssue }; setAttachments(map); }) .catch(() => {}); }, []); const [selectedKey, setSelectedKey] = useState(null); const [showDraft, setShowDraft] = useState(false); const [gamepadConnected, setGamepadConnected] = useState(false); const [splitPos, setSplitPos] = useState(null); const splitRef = useRef(null); const dragStart = useRef({ pos: 0, size: 0 }); const recognizer = useRef(new SpeechRecognizer()); const pickerRef = useRef(null); const onDividerPointerDown = useCallback((e: PointerEvent) => { e.preventDefault(); const mobile = window.innerWidth <= 768; const left = splitRef.current?.querySelector(".split-left") as HTMLElement | null; dragStart.current = { pos: mobile ? e.clientY : e.clientX, size: mobile ? (left?.offsetHeight ?? 200) : (left?.offsetWidth ?? 340), }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }, []); const onDividerPointerMove = useCallback((e: PointerEvent) => { if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return; const mobile = window.innerWidth <= 768; const delta = (mobile ? e.clientY : e.clientX) - dragStart.current.pos; const raw = dragStart.current.size + delta; const container = splitRef.current; if (!container) return; const total = mobile ? container.offsetHeight : container.offsetWidth; setSplitPos(Math.max(120, Math.min(total - 120, raw))); }, []); // Close picker on outside click useEffect(() => { if (!showWindowPicker) return; const handler = (e: MouseEvent) => { if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) setShowWindowPicker(false); }; document.addEventListener("click", handler, true); return () => document.removeEventListener("click", handler, true); }, [showWindowPicker]); const enriched = useMemo(() => { const copy = threads.map((t) => { const c = { ...t }; applyStale(c); return c; }); return autoAttach(copy, linearIssues, attachments); }, [threads, linearIssues, attachments]); const { readyThreads, workingThreads, queuedThreads, waitingThreads, archivedThreads, doneThreads } = useMemo(() => { const sort = (arr: typeof enriched) => [...arr].sort((a, b) => (b.endedAt || b.startedAt) - (a.endedAt || a.startedAt)); const working = enriched.filter((t) => t.status === "processing" || t.status === "stalled").sort((a, b) => b.startedAt - a.startedAt); const queued = enriched.filter((t) => t.status === "queued").sort((a, b) => a.startedAt - b.startedAt); const ready = sort(enriched.filter((t) => t.status === "replied")); const waiting = sort(enriched.filter((t) => t.status === "skipped")); const archived = sort(enriched.filter((t) => t.status === "archived")); const done = sort(enriched.filter((t) => !["processing", "stalled", "queued", "replied", "skipped", "archived"].includes(t.status))); return { readyThreads: ready, workingThreads: working, queuedThreads: queued, waitingThreads: waiting, archivedThreads: archived, doneThreads: done }; }, [enriched]); // Auto-select first thread if nothing selected useEffect(() => { if (selectedKey || showDraft) return; const all = [...readyThreads, ...workingThreads, ...queuedThreads, ...waitingThreads, ...doneThreads]; if (all.length > 0) { setSelectedKey(`${all[0].channel}:${all[0].threadId}`); } }, [readyThreads, workingThreads, queuedThreads, waitingThreads, doneThreads, selectedKey, showDraft]); const selectedThread = useMemo(() => { if (!selectedKey) return null; return enriched.find((t) => `${t.channel}:${t.threadId}` === selectedKey) || null; }, [enriched, selectedKey]); const handleAttach = useCallback(async (threadId: string, issueId: string) => { await apiAttach(threadId, issueId); setAttachments((prev) => ({ ...prev, [threadId]: { linearIssue: issueId } })); refresh(); }, [refresh]); const handleChangeStatus = useCallback(async (channel: string, threadId: string, newStatus: string) => { await updateThreadStatus(channel, threadId, newStatus); refresh(); }, [refresh]); const handleDelete = useCallback(async (channel: string, threadId: string) => { await apiDelete(channel, threadId); if (selectedKey === `${channel}:${threadId}`) setSelectedKey(null); refresh(); }, [refresh, selectedKey]); const handleArchive = useCallback(async (channel: string, threadId: string) => { await updateThreadStatus(channel, threadId, "archived"); refresh(); }, [refresh]); const handleSelect = useCallback((key: string) => { setSelectedKey(key); setShowDraft(false); }, []); const handleNewChat = useCallback(() => { setShowDraft(true); setSelectedKey(null); }, []); const handleDraftSent = useCallback((threadId: string) => { setSelectedKey(`code:${threadId}`); refresh(); }, [refresh]); const handleDebugThread = useCallback(async (thread: ThreadData) => { const sessionPath = thread.sessionFile ? `palaceplatform/channels/logs/sessions/${thread.sessionFile}` : null; const lines = [ `Debug investigation requested for a stalled thread.`, ``, `Subject: ${thread.subject}`, `Channel: ${thread.channel}`, `Thread ID: ${thread.threadId}`, `Started: ${new Date(thread.startedAt).toISOString()}`, `Duration before stalling: ${fmtDur((thread.endedAt || Date.now()) - thread.startedAt)}`, thread.linearIssue ? `Linear issue: ${thread.linearIssue}${thread.linearTitle ? ` — ${thread.linearTitle}` : ""}` : null, sessionPath ? `Session log: ${sessionPath}` : null, ``, `Please read the session log, identify the last tool call and what it was doing, determine why the process stopped without completing, and report your findings.`, ].filter(Boolean).join("\n"); const result = await sendChatStream(null, lines, () => {}, { title: `Debug: ${thread.subject}` }); handleDraftSent(result.threadId); }, [handleDraftSent]); const handleDraftClose = useCallback(() => { setShowDraft(false); }, []); useGamepad({ onConnected: setGamepadConnected, onMic() { (document.querySelector(".chat-reply-mic") as HTMLElement | null)?.click(); }, }); const activeCount = enriched.filter((t) => t.status === "processing").length; const nonArchived = enriched.filter((t) => t.status !== "archived"); const renderGroup = (label: string, items: typeof readyThreads) => { if (items.length === 0) return null; return (
{label}
{items.map((t) => { const key = `${t.channel}:${t.threadId}`; return ( handleSelect(key)} onChangeStatus={(s) => handleChangeStatus(t.channel, t.threadId, s)} onDelete={() => handleDelete(t.channel, t.threadId)} onArchive={() => handleArchive(t.channel, t.threadId)} onDebug={() => handleDebugThread(t)} /> ); })}
); }; return ( <>

Palace Code

{showWindowPicker && (
{WINDOWS.map((w, i) => (
{ setWindowIdx(i); setShowWindowPicker(false); }} > {w.label}
))}
{ setShowArchived(!showArchived); setShowWindowPicker(false); }} > {showArchived ? "✓ " : ""}Archived
)}
{gamepadConnected && ( Controller )}
{nonArchived.length === 0 && !showArchived ? (
No threads
) : ( <> {renderGroup("Junwon to reply", readyThreads)} {renderGroup("Ace is working on this", workingThreads)} {renderGroup("Queued", queuedThreads)} {renderGroup("Waiting", waitingThreads)} {renderGroup("Done", doneThreads)} {showArchived && renderGroup("Archived", archivedThreads)} )}
{showDraft ? ( ) : selectedThread ? ( handleChangeStatus(selectedThread.channel, selectedThread.threadId, s)} onDelete={() => handleDelete(selectedThread.channel, selectedThread.threadId)} recognizer={recognizer.current} onChatSent={refresh} /> ) : (
Select a thread to view details
)}
); }