import {
useCallback,
useEffect,
useRef,
useState,
} from "react"
import {
Archive,
ArrowRight,
BookmarkPlus,
ChevronDown,
Command as CommandIcon,
Copy,
Download,
FileText,
Loader2,
Mic,
MicOff,
Plus,
RefreshCw,
RotateCcw,
Square,
Trash2,
Undo2,
X,
} from "lucide-react"
import {
LLMProvider,
MockLLM,
listModels,
useChat,
useCompletion,
type LLMAdapter,
} from "@crema/llm-ui"
import {
buildAdapter,
getProvider,
useSettings as useProviderSettings,
} from "@crema/llm-providers-ui"
import { TypingIndicator } from "@crema/chat-ui"
import { AppShell } from "~/components/layout/app-shell"
import { MessageBody } from "~/components/assistant/message-body"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import {
loadActiveAgentId,
saveActiveAgentId,
useAgents,
type Agent,
} from "~/lib/agents"
import { addLibraryItem } from "~/lib/library"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { pageTitle } from "~/lib/page-meta"
import { useArcadiaClient } from "@crema/arcadia-client"
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
import {
AgentAvatar,
ToolCallCard,
type ToolCall as AgentToolCall,
type ToolCallStatus,
} from "@crema/agent-ui"
import {
buildDenialMessages,
classifyCalls,
getOpenAITools,
runLLMToolCalls,
} from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import { formatAdminContextForPrompt } from "~/lib/admin-context"
import { ConfirmCard } from "~/components/assistant/confirm-card"
import { renderToolResult } from "~/components/assistant/tool-result-renderers"
function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
const rich = renderToolResult(name, result)
if (!rich) return null
return
{rich}
}
const SNAPSHOT_KEY = "crema.ai.snapshot"
// Separate key for the live conversation that survives navigation. The
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
const LIVE_KEY = "crema.ai.live"
function loadLive(): LLMMessage[] | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(LIVE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed as LLMMessage[]
} catch {}
return null
}
function saveLive(msgs: LLMMessage[]) {
if (typeof window === "undefined") return
if (msgs.length === 0) {
localStorage.removeItem(LIVE_KEY)
return
}
try {
localStorage.setItem(LIVE_KEY, JSON.stringify(msgs))
} catch {
// Quota exceeded or similar — silently drop persistence.
}
}
function clearLive() {
if (typeof window === "undefined") return
localStorage.removeItem(LIVE_KEY)
}
type StoredMessage = { role: "user" | "assistant"; content: string }
function loadAISnapshot(): StoredMessage[] | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(SNAPSHOT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed as StoredMessage[]
} catch {}
return null
}
function saveAISnapshot(msgs: StoredMessage[]) {
if (typeof window === "undefined") return
localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(msgs))
}
function clearAISnapshot() {
if (typeof window === "undefined") return
localStorage.removeItem(SNAPSHOT_KEY)
}
export const meta = () => pageTitle("AI")
const MODEL_KEY = "crema.ai.model"
const PROBE_TIMEOUT_MS = 3000
type Status =
| { kind: "probing" }
| { kind: "live"; models: string[] }
| { kind: "mock"; reason: string }
const mockAdapter = new MockLLM({
label: "Mock",
delayMs: 18,
fallback:
"I'm a stand-in for the local model. Start LM Studio at localhost:1234 and reload to swap me out.",
responses: [],
})
function withTimeout(p: Promise, ms: number, signal: AbortSignal) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error("timeout")), ms)
signal.addEventListener("abort", () => {
clearTimeout(t)
reject(new DOMException("Aborted", "AbortError"))
})
p.then(
(v) => {
clearTimeout(t)
resolve(v)
},
(e) => {
clearTimeout(t)
reject(e)
},
)
})
}
export default function AIRoute() {
const settings = useProviderSettings()
const arcadia = useArcadiaClient()
const provider = getProvider(settings.providerId)
const agents = useAgents()
const [status, setStatus] = useState({ kind: "probing" })
const [model, setModel] = useState(() => {
if (typeof window === "undefined") return ""
return localStorage.getItem(MODEL_KEY) ?? ""
})
const [adapter, setAdapter] = useState(mockAdapter)
const [activeAgentId, setActiveAgentIdState] = useState(() =>
loadActiveAgentId(),
)
const setActiveAgentId = useCallback((id: string) => {
saveActiveAgentId(id)
setActiveAgentIdState(id)
}, [])
const activeAgent =
agents.find((a) => a.id === activeAgentId) ?? agents[0]
// When the user changes provider/model in Settings, follow along.
useEffect(() => {
if (settings.model) setModel(settings.model)
}, [settings.providerId, settings.model])
// Resolve the API key from the vault (direct mode) or build the proxy
// adapter (proxy mode), then refresh the model list.
const probe = useCallback(() => {
const ac = new AbortController()
setStatus({ kind: "probing" })
const resolveSecret = async (name: string): Promise => {
const res = await arcadia.GET<{ data: { value: string } }>(
`/api/v1/secrets/${encodeURIComponent(name)}`,
)
return res.data.value
}
const arcadiaBaseURL =
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
const arcadiaTenantId =
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
const arcadiaAuthToken =
typeof window !== "undefined"
? sessionStorage.getItem("arcadia_access_token") ?? undefined
: undefined
;(async () => {
// Build the adapter first so chat works even if the model probe fails.
try {
const a = await buildAdapter({
settings,
resolveSecret,
arcadiaBaseURL,
arcadiaAuthToken,
arcadiaTenantId,
})
setAdapter(a)
} catch {
setAdapter(mockAdapter)
}
// Probe for a live model list. Anthropic has no /models endpoint, so
// fall back to the provider catalog's default models.
if (provider.transport === "anthropic") {
const ids = provider.defaultModels.length
? provider.defaultModels
: ["claude-opus-4-7"]
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
return
}
const baseURL = settings.baseURL || provider.baseURL
let apiKey: string | undefined
if (provider.requiresKey && settings.secretName) {
try {
apiKey = await resolveSecret(settings.secretName)
} catch {
// Fall through; listModels may still work for some providers without a key.
}
}
try {
const rows = await withTimeout(
listModels({ baseURL, apiKey, signal: ac.signal }),
PROBE_TIMEOUT_MS,
ac.signal,
)
const ids = rows.map((m) => m.id)
if (ids.length === 0) {
setStatus({ kind: "mock", reason: "endpoint returned no models" })
return
}
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
} catch {
// Probe failed but adapter may still be usable; show the catalog default
// models so the user can pick one and just try sending.
if (provider.defaultModels.length) {
setStatus({ kind: "live", models: provider.defaultModels })
setModel((cur) =>
cur && provider.defaultModels.includes(cur)
? cur
: settings.model || provider.defaultModels[0],
)
} else {
setStatus({ kind: "mock", reason: "endpoint unreachable" })
}
}
})()
return () => ac.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
arcadia,
settings.providerId,
settings.baseURL,
settings.secretName,
settings.mode,
settings.model,
provider.transport,
provider.baseURL,
provider.requiresKey,
])
useEffect(() => probe(), [probe])
useEffect(() => {
if (model) localStorage.setItem(MODEL_KEY, model)
}, [model])
const activeModel =
status.kind === "live" ? model || status.models[0] : "mock"
const availableModels = status.kind === "live" ? status.models : ["mock"]
return (
{/* Console aesthetic is scoped to this wrapper only, so the appbar
* and sidebar keep using the global skyrise tokens (light/dark
* toggle still works for them). */}
)
}
function ChatSurface({
models,
model,
onModelChange,
agents,
activeAgent,
onAgentChange,
isMock,
onRetryProbe,
}: {
models: string[]
model: string
onModelChange: (m: string) => void
agents: Agent[]
activeAgent: Agent | undefined
onAgentChange: (id: string) => void
isMock: boolean
onRetryProbe: () => void
}) {
const persona = activeAgent
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
: ""
const systemPrompt = [
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
ARCADIA_KNOWLEDGE,
persona,
formatAdminContextForPrompt(),
]
.filter(Boolean)
.join("\n\n")
const arcadia = useArcadiaClient()
// Hydrate from the persisted live conversation so navigating away and
// back doesn't reset the chat. Read once on mount.
const initialLive = useRef(null)
if (initialLive.current === null) {
initialLive.current = loadLive() ?? []
}
const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({
system: systemPrompt,
initialMessages: initialLive.current,
})
// Persist on every change. Streaming partials get saved too, which is what
// we want — refreshing mid-stream restores the partial assistant message.
useEffect(() => {
saveLive(messages)
}, [messages])
// Wrap reset so "Clear conversation" also drops the persisted snapshot.
const resetAndClear = useCallback(() => {
reset()
clearLive()
}, [reset])
// Auto tool-loop using native function calls. Reads run automatically;
// writes are held in `pendingConfirm` until the operator clicks Confirm
// or Deny in the inline ConfirmCard.
const toolIterationsRef = useRef(0)
const processedTurnRef = useRef(-1)
const prevStreamingRef = useRef(isStreaming)
const MAX_TOOL_ITERATIONS = 3
const [pendingConfirm, setPendingConfirm] = useState<{
/** Message index that emitted the write calls. */
afterIndex: number
writes: ToolCall[]
readMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
} | null>(null)
const [confirmBusy, setConfirmBusy] = useState(false)
useEffect(() => {
const justFinished = prevStreamingRef.current && !isStreaming
prevStreamingRef.current = isStreaming
if (!justFinished) return
const lastIdx = messages.length - 1
if (lastIdx < 0) return
const last = messages[lastIdx]
if (last.role !== "assistant") return
if (processedTurnRef.current === lastIdx) return
processedTurnRef.current = lastIdx
const calls = last.toolCalls ?? []
if (calls.length === 0) {
toolIterationsRef.current = 0
return
}
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) return
toolIterationsRef.current += 1
void (async () => {
const { reads, writes } = classifyCalls(calls)
const { toolMessages: readMsgs } =
reads.length > 0
? await runLLMToolCalls(reads, { arcadia })
: { toolMessages: [] }
if (writes.length > 0) {
setPendingConfirm({ afterIndex: lastIdx, writes, readMessages: readMsgs })
return
}
void continueChat(readMsgs, {
system: systemPrompt,
tools: getOpenAITools(),
})
})()
}, [messages, isStreaming, arcadia, continueChat, systemPrompt])
const onConfirmWrites = useCallback(async () => {
if (!pendingConfirm) return
setConfirmBusy(true)
try {
const { toolMessages: writeMsgs } = await runLLMToolCalls(
pendingConfirm.writes,
{ arcadia },
{ allowWrites: true },
)
void continueChat([...pendingConfirm.readMessages, ...writeMsgs], {
system: systemPrompt,
tools: getOpenAITools(),
})
} finally {
setPendingConfirm(null)
setConfirmBusy(false)
}
}, [pendingConfirm, arcadia, continueChat, systemPrompt])
const onDenyWrites = useCallback(() => {
if (!pendingConfirm) return
const denials = buildDenialMessages(pendingConfirm.writes)
void continueChat([...pendingConfirm.readMessages, ...denials], {
system: systemPrompt,
tools: getOpenAITools(),
})
setPendingConfirm(null)
}, [pendingConfirm, continueChat, systemPrompt])
const { complete: completeOneShot, isLoading: compacting } = useCompletion()
const [input, setInput] = useState("")
const [showPromptOpen, setShowPromptOpen] = useState(false)
const [hasCompactSnapshot, setHasCompactSnapshot] = useState(
() => !!loadAISnapshot(),
)
// Session label — stable for the duration of the page load. Encoded in
// base36 from the mount timestamp; just a unique-feeling moniker for
// the operator's eye, not anything semantic.
const [sessionLabel] = useState(() =>
typeof window === "undefined"
? "0000-0000"
: `${Math.floor(Date.now() / 1000).toString(36).slice(-4).toUpperCase()}-${Math.random()
.toString(36)
.slice(2, 6)
.toUpperCase()}`,
)
// Live clock for the modeline / signatures, ticking every second.
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
const clockLabel = now
.toISOString()
.slice(11, 19) /* HH:MM:SS in UTC */
+ "Z"
const hasAssistantReply = messages.some((m) => m.role === "assistant")
const buildTranscript = useCallback(() => {
const lines: string[] = [
`# Conversation`,
"",
activeAgent
? `**Persona:** ${activeAgent.name} — ${activeAgent.role}`
: "",
`**Date:** ${new Date().toISOString()}`,
"",
].filter(Boolean)
for (const m of messages) {
lines.push(`### ${m.role === "user" ? "User" : "Assistant"}`)
lines.push("")
lines.push(m.content.trim())
lines.push("")
}
return lines.join("\n")
}, [messages, activeAgent])
const copyMarkdown = useCallback(async () => {
if (messages.length === 0) return
try {
await navigator.clipboard.writeText(buildTranscript())
} catch {}
}, [buildTranscript, messages.length])
const exportMarkdown = useCallback(() => {
if (messages.length === 0) return
const md = buildTranscript()
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
a.download = `ai-${stamp}.md`
a.click()
URL.revokeObjectURL(url)
}, [buildTranscript, messages.length])
const saveToLibrary = useCallback(() => {
if (messages.length === 0) return
const md = buildTranscript()
addLibraryItem({
kind: "conversation",
title:
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
"AI conversation",
content: md,
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
agentName: activeAgent?.name,
agentRole: activeAgent?.role,
messageCount: messages.length,
})
}, [buildTranscript, messages, activeAgent])
const regenerateLast = useCallback(() => {
if (isStreaming) return
let lastUserIdx = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
lastUserIdx = i
break
}
}
if (lastUserIdx === -1) return
const text = messages[lastUserIdx].content
setMessages(messages.slice(0, lastUserIdx))
// Defer so the state flush completes before send() reads `messages`.
setTimeout(() => void send(text, { tools: getOpenAITools() }), 0)
}, [messages, setMessages, send, isStreaming])
const continueLast = useCallback(() => {
if (isStreaming || messages.length === 0) return
void send("Please continue your previous reply.", { tools: getOpenAITools() })
}, [isStreaming, messages.length, send])
const compactConversation = useCallback(async () => {
if (compacting || isStreaming || messages.length < 2) return
const transcript = messages
.map(
(m) =>
`${m.role === "user" ? "User" : "Assistant"}: ${m.content.trim()}`,
)
.join("\n\n")
const summarySystem =
"You compress conversations. Output a tight 1–2 paragraph summary that preserves: user goals, key facts, names, decisions, file paths, code snippets, and unfinished tasks. Use third person ('the user wants X'). No commentary, no preamble, no markdown headings."
try {
const summary = await completeOneShot(
[
{ role: "system", content: summarySystem },
{
role: "user",
content: `Summarize this conversation:\n\n${transcript}`,
},
],
{ maxTokens: 800 },
)
// Snapshot first so Restore can undo.
saveAISnapshot(messages as StoredMessage[])
setHasCompactSnapshot(true)
setMessages([
{
role: "assistant",
content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`,
},
])
} catch {}
}, [compacting, isStreaming, messages, completeOneShot, setMessages])
const restoreCompact = useCallback(() => {
const snap = loadAISnapshot()
if (!snap) return
setMessages(snap)
clearAISnapshot()
setHasCompactSnapshot(false)
}, [setMessages])
const endRef = useRef(null)
const composerRef = useRef(null)
const lastContent = messages.at(-1)?.content ?? ""
// Track the composer's actual height so the auto-scroll sentinel can
// keep the latest text ~24px above its top edge regardless of how many
// lines the textarea has grown to.
const [composerHeight, setComposerHeight] = useState(160)
useEffect(() => {
const el = composerRef.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(([entry]) => {
if (entry) setComposerHeight(entry.contentRect.height)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// Auto-stick to the bottom only when the user is already near it. If they
// scroll up to read earlier turns mid-stream, don't yank them back down.
// The ref dodges React render cycles so scroll events feel instant.
const stickRef = useRef(true)
useEffect(() => {
const onScroll = () => {
const distFromBottom =
document.documentElement.scrollHeight -
(window.scrollY + window.innerHeight)
stickRef.current = distFromBottom < 120
}
window.addEventListener("scroll", onScroll, { passive: true })
onScroll()
return () => window.removeEventListener("scroll", onScroll)
}, [])
useEffect(() => {
if (!stickRef.current) return
endRef.current?.scrollIntoView({ block: "end" })
}, [messages.length, lastContent, isStreaming])
const submit = useCallback(() => {
const text = input.trim()
if (!text || isStreaming) return
setInput("")
stickRef.current = true
void send(text, { tools: getOpenAITools() })
}, [input, isStreaming, send])
const isEmpty = messages.length === 0
// Token estimate for the modeline. Cheap heuristic, adequate for
// operator-glance display.
const estTokensTotal = messages.reduce(
(n, m) => n + Math.ceil((m.content?.length ?? 0) / 4),
0,
)
const userTurns = messages.filter((m) => m.role === "user").length
return (
{/* Session header — flight-recorder strip. Hidden in the empty state
* because the empty state already shows session metadata. */}
{!isEmpty && (
session
{sessionLabel.split("-")[0]}
·
{sessionLabel.split("-")[1]}
)}
{/* Empty state — flight-recorder card with staggered reveal */}
arcadia // operator console
session {sessionLabel}
ATLAS.
standing by
› {" "}
Issue an instruction. Read tools run automatically. Writes pause for
confirmation. Tab ⇥ for command palette.
{/* Messages — rendered when there are any. In empty state a flex-grow
* spacer takes its place so the sticky-bottom composer lands at the
* actual bottom of the surface (otherwise it'd sit at the top with
* nothing above it, and the lift transform would push it off-screen). */}
{isEmpty ? (
) : (
{messages.map((m, i) => {
if (m.role === "system" || m.role === "tool") return null
const calls =
m.role === "assistant" && m.toolCalls
? m.toolCalls.map((tc) =>
buildAgentToolCall(tc, messages, isStreaming, !!pendingConfirm),
)
: []
const isWritePending =
pendingConfirm?.afterIndex === i ? pendingConfirm.writes : null
return (
{calls.length > 0 && (
{calls.map((c) => (
{c.status === "success" && (
)}
))}
)}
{isWritePending && (
)}
)
})}
{(() => {
const activity = deriveAgentActivity({
isStreaming,
lastMessage: messages.at(-1),
pendingConfirm: !!pendingConfirm,
confirmBusy,
})
const isIdle = activity === "idle"
return (
)
})()}
)}
{/* Composer — single persistent mount; transform lifts it to viewport
* center when empty, then springs to sticky-bottom on the first message. */}
void compactConversation()}
onRestoreCompact={restoreCompact}
onCopyMarkdown={() => void copyMarkdown()}
onExportMarkdown={exportMarkdown}
onSaveToLibrary={saveToLibrary}
onShowPrompt={() => setShowPromptOpen(true)}
onRetryProbe={onRetryProbe}
onClear={resetAndClear}
hasMessages={messages.length > 0}
hasUserMessage={messages.some((m) => m.role === "user")}
hasCompactSnapshot={hasCompactSnapshot}
isMock={isMock}
isCompacting={compacting}
placeholder={isEmpty ? "Ask anything…" : "Reply…"}
/>
{showPromptOpen && (
setShowPromptOpen(false)}
/>
)}
{/* Modeline — vim-style status strip. Pinned above the AppShell's own
* footer/padding so it always reads in the operator's bottom band. */}
utc
{clockLabel}
turn
{userTurns.toString().padStart(2, "0")}
tok
~{estTokensTotal.toLocaleString()}
{isStreaming ? (
STREAM
) : (
enter
send
⇧
enter
newline
)}
)
}
function deriveAgentActivity({
isStreaming,
lastMessage,
pendingConfirm,
confirmBusy,
}: {
isStreaming: boolean
lastMessage: LLMMessage | undefined
pendingConfirm: boolean
confirmBusy: boolean
}): "idle" | "thinking" | "working" | "waiting" | "speaking" {
if (confirmBusy) return "working"
if (pendingConfirm) return "waiting"
if (!isStreaming) return "idle"
if (!lastMessage || lastMessage.role !== "assistant") return "thinking"
if (lastMessage.content.trim().length > 0) return "speaking"
return "thinking"
}
function buildAgentToolCall(
tc: ToolCall,
allMessages: LLMMessage[],
isStreaming: boolean,
pendingConfirm: boolean,
): AgentToolCall {
const result = allMessages.find(
(m) => m.role === "tool" && m.toolCallId === tc.id,
)
let parsedArgs: Record | undefined
try {
parsedArgs = tc.arguments ? JSON.parse(tc.arguments) : undefined
} catch {
parsedArgs = undefined
}
if (result) {
let parsedResult: unknown = result.content
let errorMsg: string | undefined
try {
const obj = JSON.parse(result.content) as Record
if (obj && typeof obj === "object" && typeof obj.error === "string") {
errorMsg = obj.error
} else {
parsedResult = obj
}
} catch {
// leave as raw text
}
return {
id: tc.id,
name: tc.name,
status: errorMsg ? "error" : "success",
args: parsedArgs,
result: errorMsg ? undefined : parsedResult,
error: errorMsg,
}
}
let status: ToolCallStatus = "running"
if (pendingConfirm) status = "pending"
else if (!isStreaming) status = "running"
return {
id: tc.id,
name: tc.name,
status,
args: parsedArgs,
}
}
function SessionMeta({
label,
value,
tone = "default",
}: {
label: string
value: string
tone?: "default" | "amber" | "rose" | "mint" | "muted"
}) {
const toneColor = {
default: "var(--console-text)",
amber: "var(--console-amber)",
rose: "var(--console-rose)",
mint: "var(--console-mint)",
muted: "var(--console-muted)",
}[tone]
return (
{label}
{value}
)
}
function truncateModel(m: string): string {
if (!m) return "—"
if (m.length <= 22) return m
return m.slice(0, 10) + "…" + m.slice(-9)
}
function MessageRow({
role,
content,
toolCalls,
turnNum,
agentName,
timestamp,
}: {
role: "user" | "assistant"
content: string
toolCalls?: ToolCall[]
turnNum?: number
agentName?: string
timestamp?: string
}) {
// Operator turn — monospace, sodium-amber prompt, no bubble. The whole
// row hangs from a left gutter showing the turn number.
if (role === "user") {
return (
T{(turnNum ?? 0).toString().padStart(2, "0")}
{timestamp ? (
{timestamp}
) : null}
)
}
// Assistant turn — set in serif, with a tiny mono signature beneath. If
// there's no prose (just tool calls), suppress the row entirely.
if (!content.trim()) return null
return (
T{(turnNum ?? 0).toString().padStart(2, "0")}
{agentName?.slice(0, 6).toLowerCase() ?? "atlas"}
{agentName?.toLowerCase() ?? "atlas"}»
{timestamp ? {timestamp} : null}
·
recv
)
}
function Composer({
value,
onChange,
onSubmit,
onAbort,
isStreaming,
models,
model,
onModelChange,
agents,
activeAgent,
onAgentChange,
onRegenerate,
onContinue,
onCompact,
onRestoreCompact,
onCopyMarkdown,
onExportMarkdown,
onSaveToLibrary,
onShowPrompt,
onRetryProbe,
onClear,
hasMessages,
hasUserMessage,
hasCompactSnapshot,
isMock,
isCompacting,
placeholder,
}: {
value: string
onChange: (v: string) => void
onSubmit: () => void
onAbort: () => void
isStreaming: boolean
models: string[]
model: string
onModelChange: (m: string) => void
agents: Agent[]
activeAgent: Agent | undefined
onAgentChange: (id: string) => void
onRegenerate: () => void
onContinue: () => void
onCompact: () => void
onRestoreCompact: () => void
onCopyMarkdown: () => void
onExportMarkdown: () => void
onSaveToLibrary: () => void
onShowPrompt: () => void
onRetryProbe: () => void
onClear: () => void
hasMessages: boolean
hasUserMessage: boolean
hasCompactSnapshot: boolean
isMock: boolean
isCompacting: boolean
placeholder: string
}) {
const taRef = useRef(null)
// Auto-grow the textarea.
useEffect(() => {
const el = taRef.current
if (!el) return
el.style.height = "auto"
el.style.height = Math.min(el.scrollHeight, 240) + "px"
}, [value])
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}
return (
›_
onChange((value ? value + " " : "") + t)}
/>
{isStreaming ? (
) : null}
)
}
function ModelSelector({
models,
model,
onModelChange,
}: {
models: string[]
model: string
onModelChange: (m: string) => void
}) {
const label = prettyModelName(model)
return (
{label}
{models.map((m) => (
onModelChange(m)}
className={m === model ? "font-medium" : ""}
>
{prettyModelName(m)}
))}
)
}
function AgentChip({
agents,
activeAgent,
onAgentChange,
}: {
agents: Agent[]
activeAgent: Agent | undefined
onAgentChange: (id: string) => void
}) {
return (
{agentInitials(activeAgent?.name)}
{activeAgent?.name ?? "Agent"}
Switch persona
{agents.map((a) => (
onAgentChange(a.id)}
data-state={activeAgent?.id === a.id ? "checked" : undefined}
data-action={`ai-agent-${a.id}`}
className="flex items-center gap-2.5"
>
{agentInitials(a.name)}
{a.name}
{a.role}
))}
)
}
function agentInitials(name: string | undefined): string {
if (!name) return "?"
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}
function agentTint(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0
}
const hue = ((hash % 360) + 360) % 360
return `oklch(0.55 0.18 ${hue})`
}
function CommandsMenu({
onRegenerate,
onContinue,
onCompact,
onRestoreCompact,
onCopyMarkdown,
onExportMarkdown,
onSaveToLibrary,
onShowPrompt,
onRetryProbe,
onClear,
isStreaming,
isCompacting,
hasMessages,
hasUserMessage,
hasCompactSnapshot,
isMock,
}: {
onRegenerate: () => void
onContinue: () => void
onCompact: () => void
onRestoreCompact: () => void
onCopyMarkdown: () => void
onExportMarkdown: () => void
onSaveToLibrary: () => void
onShowPrompt: () => void
onRetryProbe: () => void
onClear: () => void
isStreaming: boolean
isCompacting: boolean
hasMessages: boolean
hasUserMessage: boolean
hasCompactSnapshot: boolean
isMock: boolean
}) {
return (
}
/>
Conversation
}
label="Regenerate"
title="Re-run the most recent prompt"
/>
}
label="Continue"
title="Ask the model to keep going"
/>
) : (
)
}
label="Compact"
title="Summarize older turns to free context"
/>
}
label="Restore"
title={
hasCompactSnapshot
? "Undo the most recent compact"
: "No snapshot available"
}
/>
Share
}
label="Copy MD"
title="Copy conversation as Markdown"
/>
}
label="Export MD"
title="Download a .md file"
/>
}
label="Save to Library"
title="Snapshot this conversation"
/>
}
label="Show prompt"
title="Preview the system prompt"
/>
{isMock && (
<>
Connection
}
label="Reconnect"
title="Probe the LLM endpoint again"
/>
>
)}
}
label="Clear conversation"
title="Wipe history and start fresh"
destructive
fullWidth
/>
)
}
function SystemPromptDialog({
prompt,
onClose,
}: {
prompt: string
onClose: () => void
}) {
const copy = async () => {
try {
await navigator.clipboard.writeText(prompt)
} catch {}
}
return (
e.stopPropagation()}
className="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border bg-card shadow-lg"
>
System prompt
Base prompt + active persona
Copy
{prompt}
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
function ToolTile({
icon,
label,
title,
onClick,
disabled,
destructive,
fullWidth,
...rest
}: {
icon: React.ReactNode
label: string
title: string
onClick: () => void
disabled?: boolean
destructive?: boolean
fullWidth?: boolean
} & Omit, "onClick" | "title">) {
return (
{icon}
{label}
)
}
function prettyModelName(id: string): string {
if (!id) return "model"
// Trim known prefixes / paths so the chip stays compact.
const last = id.split(/[\\/]/).pop() ?? id
return last.length > 28 ? last.slice(0, 26) + "…" : last
}
type SpeechRecognitionLike = {
lang: string
interimResults: boolean
continuous: boolean
onresult: (e: { results: { [k: number]: { [k: number]: { transcript: string } } } }) => void
onerror: () => void
onend: () => void
start: () => void
stop: () => void
}
function getSpeechRecognition(): (new () => SpeechRecognitionLike) | null {
if (typeof window === "undefined") return null
const w = window as unknown as {
SpeechRecognition?: new () => SpeechRecognitionLike
webkitSpeechRecognition?: new () => SpeechRecognitionLike
}
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
}
function VoiceInputButton({
onTranscript,
}: {
onTranscript: (text: string) => void
}) {
const Ctor = getSpeechRecognition()
const [listening, setListening] = useState(false)
const recRef = useRef(null)
if (!Ctor) return null
const start = () => {
try {
const rec = new Ctor()
rec.lang = navigator.language || "en-US"
rec.interimResults = false
rec.continuous = false
rec.onresult = (e) => {
const text = e.results[0]?.[0]?.transcript ?? ""
if (text.trim()) onTranscript(text.trim())
}
rec.onerror = () => setListening(false)
rec.onend = () => setListening(false)
recRef.current = rec
rec.start()
setListening(true)
} catch {
setListening(false)
}
}
const stop = () => {
recRef.current?.stop()
setListening(false)
}
return (
(listening ? stop() : start())}
aria-label={listening ? "Stop listening" : "Voice input"}
className={
"inline-flex size-9 items-center justify-center rounded-full transition-colors " +
(listening
? "bg-destructive/10 text-destructive animate-pulse"
: "text-muted-foreground hover:bg-accent hover:text-foreground")
}
>
{listening ? : }
)
}