Replaces the conventional chat aesthetic on /ai with a brutalist-mono
operator deck. The page now reads as a flight recorder — turn numbers
in the gutter, hairline rules, sodium-amber phosphor primary on
deep-ink ground, vim-style modeline at the foot.
Type system is the design's load-bearing element:
- JetBrains Mono for everything system-y (operator lines, signatures,
modeline, session ids, tool calls)
- Newsreader serif for the agent's prose only — the synthesis voice
literally lifts off the page in a different family from the machine
voice. Operator and agent are typographically inseparable from their
speaker.
Layout changes:
- Sticky session header with a giant base36 session id ("3K9P · A4C2")
and a metadata strip showing agent, model, turn count, status. The
status pill flips colour: AMBER on stream, ROSE on awaiting confirm,
MINT on ready, MUTED on mock.
- Empty state is no longer the apologetic "How can I help you today?".
It's "ATLAS. standing by." in oversize mono with the agent name in
italic serif amber, a hairline divider, and a single one-liner
instruction prefixed with ›. Lines stagger in via animation-delay.
- Operator turns: monospace, 14px, sodium-amber › prompt, no bubble.
Hangs from a left gutter with T01/T02… turn number + UTC timestamp.
- Agent turns: serif, 17px/1.55, with a tiny mono signature underneath
("atlas» 03:14:08Z · recv"). Cyan accent column instead of amber.
- Composer: terminal frame (square, 1px border, focus ring is amber
glow). Internal ›_ prompt mark in front of the textarea, mono input.
- Bottom modeline: utc clock + turn count + estimated tokens on the
left, keyboard hints on the right. Streaming flips the right side
to a pulsing phosphor bar + STREAM label.
Atmosphere details:
- 2px scanline overlay (very faint, 1.2% opacity)
- Corner phosphor blooms (amber top-right, cyan bottom-left)
- Inline SVG turbulence grain (3.5% opacity) over the whole theme
- Cursor blink animation on the prompt mark
- Consolas-tier ligatures on the mono via JetBrains Mono ss01/calt
All theming scoped via [data-theme="console"] — picks up automatically
because /ai's AppShell now passes theme="console". Other routes are
untouched. Tool-call cards from @crema/agent-ui inherit the palette
via overridden CSS variables (--card, --border, --primary, etc) plus
a [data-slot="tool-call-card"] override for the frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1718 lines
54 KiB
TypeScript
1718 lines
54 KiB
TypeScript
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 <div className="px-1">{rich}</div>
|
||
}
|
||
|
||
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<T>(p: Promise<T>, ms: number, signal: AbortSignal) {
|
||
return new Promise<T>((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<Status>({ kind: "probing" })
|
||
const [model, setModel] = useState<string>(() => {
|
||
if (typeof window === "undefined") return ""
|
||
return localStorage.getItem(MODEL_KEY) ?? ""
|
||
})
|
||
const [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
|
||
const [activeAgentId, setActiveAgentIdState] = useState<string>(() =>
|
||
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<string> => {
|
||
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 (
|
||
<AppShell title="AI" theme="console">
|
||
<LLMProvider adapter={adapter} model={activeModel}>
|
||
<ChatSurface
|
||
models={availableModels}
|
||
model={activeModel}
|
||
onModelChange={setModel}
|
||
agents={agents}
|
||
activeAgent={activeAgent}
|
||
onAgentChange={setActiveAgentId}
|
||
isMock={status.kind === "mock"}
|
||
onRetryProbe={probe}
|
||
/>
|
||
</LLMProvider>
|
||
</AppShell>
|
||
)
|
||
}
|
||
|
||
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<LLMMessage[] | null>(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<HTMLDivElement | null>(null)
|
||
const composerRef = useRef<HTMLDivElement | null>(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 (
|
||
<div className="relative -mb-6 flex h-full min-h-0 flex-col">
|
||
{/* Session header — flight-recorder strip. Hidden in the empty state
|
||
* because the empty state already shows session metadata. */}
|
||
{!isEmpty && (
|
||
<div className="console-header sticky top-0 z-10 px-4 py-3 sm:px-6">
|
||
<div className="mx-auto flex w-full max-w-3xl items-end justify-between gap-6">
|
||
<div className="flex flex-col gap-0.5">
|
||
<span className="console-meta-key">session</span>
|
||
<span className="console-session-id">
|
||
{sessionLabel.split("-")[0]}
|
||
<span>·</span>
|
||
{sessionLabel.split("-")[1]}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-wrap items-end justify-end gap-x-6 gap-y-1.5">
|
||
<SessionMeta label="agent" value={(activeAgent?.name ?? "Atlas").toLowerCase()} />
|
||
<SessionMeta label="model" value={truncateModel(model)} />
|
||
<SessionMeta label="turns" value={userTurns.toString().padStart(2, "0")} />
|
||
<SessionMeta
|
||
label="status"
|
||
value={
|
||
isStreaming
|
||
? "STREAMING"
|
||
: pendingConfirm
|
||
? "AWAIT-CONFIRM"
|
||
: isMock
|
||
? "MOCK"
|
||
: "READY"
|
||
}
|
||
tone={
|
||
isStreaming
|
||
? "amber"
|
||
: pendingConfirm
|
||
? "rose"
|
||
: isMock
|
||
? "muted"
|
||
: "mint"
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty state — flight-recorder card with staggered reveal */}
|
||
<div
|
||
aria-hidden={!isEmpty}
|
||
className="pointer-events-none absolute inset-x-0 top-[14%] px-8 transition-opacity duration-300"
|
||
style={{ opacity: isEmpty ? 1 : 0 }}
|
||
>
|
||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||
<div className="console-empty-line console-mono flex items-center justify-between text-[10.5px] tracking-[0.18em] uppercase text-[var(--console-muted)]">
|
||
<span>arcadia // operator console</span>
|
||
<span>session {sessionLabel}</span>
|
||
</div>
|
||
<div className="console-empty-line h-px bg-[var(--console-rule-soft)]" />
|
||
<h1 className="console-empty-line console-empty-headline">
|
||
ATLAS<span className="text-[var(--console-amber)]">.</span>
|
||
<br />
|
||
<em>standing by</em>
|
||
</h1>
|
||
<p className="console-empty-line console-mono max-w-[58ch] text-[13.5px] leading-[1.7] text-[var(--console-text-2)]">
|
||
<span className="text-[var(--console-amber)]">›</span>{" "}
|
||
Issue an instruction. Read tools run automatically. Writes pause for
|
||
confirmation. Tab ⇥ for command palette.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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 ? (
|
||
<div className="flex-1" aria-hidden="true" />
|
||
) : (
|
||
<div className="flex-1 px-4 py-6 sm:px-6">
|
||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||
{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 (
|
||
<div key={i} className="contents">
|
||
<MessageRow
|
||
role={m.role as "user" | "assistant"}
|
||
content={m.content}
|
||
toolCalls={m.toolCalls}
|
||
turnNum={i + 1}
|
||
agentName={activeAgent?.name ?? "Atlas"}
|
||
timestamp={clockLabel}
|
||
/>
|
||
{calls.length > 0 && (
|
||
<div className="self-start flex w-full max-w-[80ch] flex-col gap-2">
|
||
{calls.map((c) => (
|
||
<div key={c.id} className="flex flex-col gap-2">
|
||
<ToolCallCard call={c} defaultExpanded={false} />
|
||
{c.status === "success" && (
|
||
<ToolResultBlock name={c.name} result={c.result} />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{isWritePending && (
|
||
<ConfirmCard
|
||
calls={isWritePending}
|
||
onConfirm={onConfirmWrites}
|
||
onDeny={onDenyWrites}
|
||
busy={confirmBusy}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
{(() => {
|
||
const activity = deriveAgentActivity({
|
||
isStreaming,
|
||
lastMessage: messages.at(-1),
|
||
pendingConfirm: !!pendingConfirm,
|
||
confirmBusy,
|
||
})
|
||
const isIdle = activity === "idle"
|
||
return (
|
||
<div
|
||
className={`self-start transition-opacity duration-300 ${
|
||
isIdle ? "opacity-50" : "opacity-100"
|
||
}`}
|
||
>
|
||
<AgentAvatar
|
||
name={activeAgent?.name ?? "Atlas"}
|
||
activity={activity}
|
||
initials={activeAgent ? agentInitials(activeAgent.name) : "AT"}
|
||
size={isIdle ? "sm" : "md"}
|
||
showLabel={!isIdle}
|
||
/>
|
||
</div>
|
||
)
|
||
})()}
|
||
<div
|
||
ref={endRef}
|
||
aria-hidden="true"
|
||
style={{ scrollMarginBottom: `${composerHeight + 24}px` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Composer — single persistent mount; transform lifts it to viewport
|
||
* center when empty, then springs to sticky-bottom on the first message. */}
|
||
<div
|
||
ref={composerRef}
|
||
className="sticky bottom-0 z-20 px-4 pb-3 pt-3 sm:px-6"
|
||
style={{
|
||
transform: isEmpty
|
||
? "translateY(calc(-50dvh + 50% + 4rem))"
|
||
: "translateY(0)",
|
||
}}
|
||
>
|
||
<div className="mx-auto w-full max-w-3xl">
|
||
<Composer
|
||
value={input}
|
||
onChange={setInput}
|
||
onSubmit={submit}
|
||
onAbort={abort}
|
||
isStreaming={isStreaming}
|
||
models={models}
|
||
model={model}
|
||
onModelChange={onModelChange}
|
||
agents={agents}
|
||
activeAgent={activeAgent}
|
||
onAgentChange={onAgentChange}
|
||
onRegenerate={regenerateLast}
|
||
onContinue={continueLast}
|
||
onCompact={() => 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 && (
|
||
<SystemPromptDialog
|
||
prompt={systemPrompt}
|
||
onClose={() => setShowPromptOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modeline — vim-style status strip. Pinned above the AppShell's own
|
||
* footer/padding so it always reads in the operator's bottom band. */}
|
||
<div className="console-modeline px-4 py-1.5 sm:px-6">
|
||
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-x-6 gap-y-0.5 tabular-nums">
|
||
<div className="flex items-center gap-4">
|
||
<span>
|
||
<span className="console-modeline-key">utc</span>
|
||
<span className="console-modeline-val">{clockLabel}</span>
|
||
</span>
|
||
<span>
|
||
<span className="console-modeline-key">turn</span>
|
||
<span className="console-modeline-val">
|
||
{userTurns.toString().padStart(2, "0")}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
<span className="console-modeline-key">tok</span>
|
||
<span className="console-modeline-val">~{estTokensTotal.toLocaleString()}</span>
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
{isStreaming ? (
|
||
<span className="flex items-center gap-2">
|
||
<span className="console-streaming-bar" />
|
||
<span className="console-modeline-val text-[var(--console-amber)]">
|
||
STREAM
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span>
|
||
<span className="console-modeline-key">enter</span>
|
||
<span className="console-modeline-val">send</span>
|
||
<span className="ml-3 console-modeline-key">⇧</span>
|
||
<span className="console-modeline-key ml-1">enter</span>
|
||
<span className="console-modeline-val">newline</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<string, unknown> | 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<string, unknown>
|
||
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 (
|
||
<div className="flex flex-col gap-0.5 text-right">
|
||
<span className="console-meta-key">{label}</span>
|
||
<span className="console-meta-val tabular-nums" style={{ color: toneColor }}>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="grid grid-cols-[3.5rem_1fr] gap-x-3 self-stretch">
|
||
<div className="flex flex-col items-end pt-[3px]">
|
||
<span className="console-turn-num">
|
||
T{(turnNum ?? 0).toString().padStart(2, "0")}
|
||
</span>
|
||
{timestamp ? (
|
||
<span className="console-mono mt-0.5 text-[9.5px] tracking-[0.1em] text-[var(--console-muted-2)]">
|
||
{timestamp}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="border-l border-[var(--console-rule-soft)] pl-4">
|
||
<div className="console-op-line whitespace-pre-wrap">
|
||
<span className="console-op-prompt">› </span>
|
||
{content}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className="grid grid-cols-[3.5rem_1fr] gap-x-3 self-stretch">
|
||
<div className="flex flex-col items-end pt-[2px]">
|
||
<span className="console-turn-num text-[var(--console-cyan)]">
|
||
T{(turnNum ?? 0).toString().padStart(2, "0")}
|
||
</span>
|
||
<span className="console-mono mt-0.5 text-[9.5px] tracking-[0.1em] text-[var(--console-muted-2)]">
|
||
{agentName?.slice(0, 6).toLowerCase() ?? "atlas"}
|
||
</span>
|
||
</div>
|
||
<div className="border-l border-[var(--console-cyan-deep)]/40 pl-4">
|
||
<div className="console-agent-prose">
|
||
<MessageBody content={content} toolCalls={toolCalls} />
|
||
</div>
|
||
<div className="console-sig mt-2 flex items-center gap-2">
|
||
<span className="console-sig-name">
|
||
{agentName?.toLowerCase() ?? "atlas"}»
|
||
</span>
|
||
{timestamp ? <span>{timestamp}</span> : null}
|
||
<span className="text-[var(--console-muted-2)]">·</span>
|
||
<span>recv</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault()
|
||
onSubmit()
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="console-composer">
|
||
<div className="flex flex-col gap-3 px-4 pt-3 pb-3">
|
||
<div className="flex items-start gap-2">
|
||
<span
|
||
aria-hidden
|
||
className="console-mono select-none pt-[2px] text-[14px] font-semibold leading-[1.55] text-[var(--console-amber)]"
|
||
>
|
||
›_
|
||
</span>
|
||
<textarea
|
||
ref={taRef}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={onKey}
|
||
placeholder={placeholder}
|
||
rows={2}
|
||
data-action="ai-composer-input"
|
||
className="min-h-[3.5rem] w-full resize-none bg-transparent outline-none"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
data-action="ai-attach"
|
||
aria-label="Attach"
|
||
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||
>
|
||
<Plus className="size-5" />
|
||
</button>
|
||
<CommandsMenu
|
||
onRegenerate={onRegenerate}
|
||
onContinue={onContinue}
|
||
onCompact={onCompact}
|
||
onRestoreCompact={onRestoreCompact}
|
||
onCopyMarkdown={onCopyMarkdown}
|
||
onExportMarkdown={onExportMarkdown}
|
||
onSaveToLibrary={onSaveToLibrary}
|
||
onShowPrompt={onShowPrompt}
|
||
onRetryProbe={onRetryProbe}
|
||
onClear={onClear}
|
||
isStreaming={isStreaming}
|
||
isCompacting={isCompacting}
|
||
hasMessages={hasMessages}
|
||
hasUserMessage={hasUserMessage}
|
||
hasCompactSnapshot={hasCompactSnapshot}
|
||
isMock={isMock}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<AgentChip
|
||
agents={agents}
|
||
activeAgent={activeAgent}
|
||
onAgentChange={onAgentChange}
|
||
/>
|
||
<ModelSelector
|
||
models={models}
|
||
model={model}
|
||
onModelChange={onModelChange}
|
||
/>
|
||
<VoiceInputButton
|
||
onTranscript={(t) => onChange((value ? value + " " : "") + t)}
|
||
/>
|
||
{isStreaming ? (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={onAbort}
|
||
aria-label="Stop"
|
||
data-action="ai-stop"
|
||
className="rounded-full"
|
||
>
|
||
<Square className="size-4" />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ModelSelector({
|
||
models,
|
||
model,
|
||
onModelChange,
|
||
}: {
|
||
models: string[]
|
||
model: string
|
||
onModelChange: (m: string) => void
|
||
}) {
|
||
const label = prettyModelName(model)
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
data-action="ai-model"
|
||
className="inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||
>
|
||
<span>{label}</span>
|
||
<ChevronDown className="size-4 opacity-70" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{models.map((m) => (
|
||
<DropdownMenuItem
|
||
key={m}
|
||
onClick={() => onModelChange(m)}
|
||
className={m === model ? "font-medium" : ""}
|
||
>
|
||
{prettyModelName(m)}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)
|
||
}
|
||
|
||
function AgentChip({
|
||
agents,
|
||
activeAgent,
|
||
onAgentChange,
|
||
}: {
|
||
agents: Agent[]
|
||
activeAgent: Agent | undefined
|
||
onAgentChange: (id: string) => void
|
||
}) {
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
data-action="ai-agent"
|
||
className="inline-flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
title={
|
||
activeAgent
|
||
? `${activeAgent.name} — ${activeAgent.role}`
|
||
: "Pick a persona"
|
||
}
|
||
>
|
||
<Avatar className="size-5">
|
||
<AvatarFallback
|
||
style={{
|
||
background: agentTint(activeAgent?.id ?? ""),
|
||
color: "var(--primary-foreground)",
|
||
}}
|
||
className="text-[10px] font-semibold"
|
||
>
|
||
{agentInitials(activeAgent?.name)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="font-medium">
|
||
{activeAgent?.name ?? "Agent"}
|
||
</span>
|
||
<ChevronDown className="size-4 opacity-70" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
sideOffset={6}
|
||
className="max-h-80 w-72 overflow-y-auto"
|
||
>
|
||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
Switch persona
|
||
</div>
|
||
{agents.map((a) => (
|
||
<DropdownMenuItem
|
||
key={a.id}
|
||
onClick={() => onAgentChange(a.id)}
|
||
data-state={activeAgent?.id === a.id ? "checked" : undefined}
|
||
data-action={`ai-agent-${a.id}`}
|
||
className="flex items-center gap-2.5"
|
||
>
|
||
<Avatar className="size-7">
|
||
<AvatarFallback
|
||
style={{
|
||
background: agentTint(a.id),
|
||
color: "var(--primary-foreground)",
|
||
}}
|
||
className="text-[11px] font-semibold"
|
||
>
|
||
{agentInitials(a.name)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="flex min-w-0 flex-col">
|
||
<span className="truncate font-medium">{a.name}</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{a.role}
|
||
</span>
|
||
</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<Popover>
|
||
<PopoverTrigger
|
||
render={
|
||
<button
|
||
type="button"
|
||
data-action="ai-commands"
|
||
aria-label="Commands"
|
||
title="Commands"
|
||
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||
>
|
||
<CommandIcon className="size-5" />
|
||
</button>
|
||
}
|
||
/>
|
||
<PopoverContent align="start" side="top" className="w-80 p-2">
|
||
<SectionLabel>Conversation</SectionLabel>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<ToolTile
|
||
data-action="ai-cmd-regenerate"
|
||
onClick={onRegenerate}
|
||
disabled={isStreaming || !hasUserMessage}
|
||
icon={<RotateCcw className="size-4" />}
|
||
label="Regenerate"
|
||
title="Re-run the most recent prompt"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-continue"
|
||
onClick={onContinue}
|
||
disabled={isStreaming || !hasMessages}
|
||
icon={<ArrowRight className="size-4" />}
|
||
label="Continue"
|
||
title="Ask the model to keep going"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-compact"
|
||
onClick={onCompact}
|
||
disabled={isCompacting || isStreaming || !hasMessages}
|
||
icon={
|
||
isCompacting ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : (
|
||
<Archive className="size-4" />
|
||
)
|
||
}
|
||
label="Compact"
|
||
title="Summarize older turns to free context"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-restore-compact"
|
||
onClick={onRestoreCompact}
|
||
disabled={!hasCompactSnapshot}
|
||
icon={<Undo2 className="size-4" />}
|
||
label="Restore"
|
||
title={
|
||
hasCompactSnapshot
|
||
? "Undo the most recent compact"
|
||
: "No snapshot available"
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<SectionLabel>Share</SectionLabel>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<ToolTile
|
||
data-action="ai-cmd-copy-md"
|
||
onClick={onCopyMarkdown}
|
||
disabled={!hasMessages}
|
||
icon={<Copy className="size-4" />}
|
||
label="Copy MD"
|
||
title="Copy conversation as Markdown"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-export-md"
|
||
onClick={onExportMarkdown}
|
||
disabled={!hasMessages}
|
||
icon={<Download className="size-4" />}
|
||
label="Export MD"
|
||
title="Download a .md file"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-save-library"
|
||
onClick={onSaveToLibrary}
|
||
disabled={!hasMessages}
|
||
icon={<BookmarkPlus className="size-4" />}
|
||
label="Save to Library"
|
||
title="Snapshot this conversation"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-show-prompt"
|
||
onClick={onShowPrompt}
|
||
icon={<FileText className="size-4" />}
|
||
label="Show prompt"
|
||
title="Preview the system prompt"
|
||
/>
|
||
</div>
|
||
|
||
{isMock && (
|
||
<>
|
||
<SectionLabel>Connection</SectionLabel>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<ToolTile
|
||
data-action="ai-cmd-retry-probe"
|
||
onClick={onRetryProbe}
|
||
icon={<RefreshCw className="size-4" />}
|
||
label="Reconnect"
|
||
title="Probe the LLM endpoint again"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="my-2 h-px bg-border" />
|
||
<ToolTile
|
||
data-action="ai-cmd-clear"
|
||
onClick={onClear}
|
||
disabled={!hasMessages}
|
||
icon={<Trash2 className="size-4" />}
|
||
label="Clear conversation"
|
||
title="Wipe history and start fresh"
|
||
destructive
|
||
fullWidth
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
)
|
||
}
|
||
|
||
function SystemPromptDialog({
|
||
prompt,
|
||
onClose,
|
||
}: {
|
||
prompt: string
|
||
onClose: () => void
|
||
}) {
|
||
const copy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(prompt)
|
||
} catch {}
|
||
}
|
||
return (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
onClick={onClose}
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm"
|
||
>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border bg-card shadow-lg"
|
||
>
|
||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||
<FileText className="size-4 text-muted-foreground" />
|
||
<div className="flex flex-1 flex-col">
|
||
<span className="text-sm font-semibold">System prompt</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
Base prompt + active persona
|
||
</span>
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={copy}>
|
||
<Copy className="size-3.5" /> Copy
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onClose}
|
||
aria-label="Close"
|
||
>
|
||
<X className="size-4" />
|
||
</Button>
|
||
</div>
|
||
<pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
|
||
{prompt}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="mt-2 mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "title">) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
title={title}
|
||
className={
|
||
"flex items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 " +
|
||
(fullWidth ? "w-full " : "") +
|
||
(destructive
|
||
? "text-destructive hover:bg-destructive/10"
|
||
: "hover:bg-accent hover:text-accent-foreground")
|
||
}
|
||
{...rest}
|
||
>
|
||
<span className="shrink-0">{icon}</span>
|
||
<span className="truncate font-medium">{label}</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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<SpeechRecognitionLike | null>(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 (
|
||
<button
|
||
type="button"
|
||
data-action="ai-voice"
|
||
onClick={() => (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 ? <MicOff className="size-5" /> : <Mic className="size-5" />}
|
||
</button>
|
||
)
|
||
}
|