Two related fixes for the "switching agent mid-thread loses context"
issue:
1. LLM-context fix
The system prompt now includes a PRIOR HAND-OFF block whenever the
current conversation has been touched by more than one agent. It
lists the prior personas (name + role) and tells the new agent:
"Earlier turns were produced by other personas. Read them as
context, but answer in your own voice as the current persona."
Without this, switching from Atlas (Operator) to Pythia (Researcher)
left Pythia answering as if she'd produced Atlas's prior turns.
Tracked via two-trigger useEffect:
- On agent change with messages already in the thread, the prior
agent gets locked into history.
- On stream finish, the current active agent gets added (it just
produced a turn).
Cleared with the conversation.
2. UI-attribution fix
Each assistant turn now records which agent produced it
(messageAgents map: index -> Agent). The row signature in
MessageRow now reads that stamped agent rather than always echoing
the currently-active one. Switching agents mid-thread no longer
retroactively re-attributes prior responses.
Both maps are wiped by Clear conversation alongside the live snapshot
and initialLive ref, so a fresh thread starts truly fresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1836 lines
59 KiB
TypeScript
1836 lines
59 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">
|
||
<LLMProvider adapter={adapter} model={activeModel}>
|
||
{/* 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). */}
|
||
<div
|
||
data-theme="console"
|
||
className="-m-6 flex h-full min-h-0 flex-col bg-[var(--console-ink)] text-[var(--console-text)]"
|
||
>
|
||
<ChatSurface
|
||
models={availableModels}
|
||
model={activeModel}
|
||
onModelChange={setModel}
|
||
agents={agents}
|
||
activeAgent={activeAgent}
|
||
onAgentChange={setActiveAgentId}
|
||
isMock={status.kind === "mock"}
|
||
onRetryProbe={probe}
|
||
/>
|
||
</div>
|
||
</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}`
|
||
: ""
|
||
|
||
// Track every agent that has produced turns in the current conversation.
|
||
// When the operator switches mid-thread, we augment the system prompt so
|
||
// the new agent knows it's stepping into a transcript started by another
|
||
// persona — without that note it answers as if it produced every prior
|
||
// turn itself, which is jarring.
|
||
const [agentHistory, setAgentHistory] = useState<Map<string, Agent>>(
|
||
() => new Map(),
|
||
)
|
||
const prevAgentRef = useRef<Agent | undefined>(activeAgent)
|
||
|
||
// Per-message agent attribution: which agent produced the assistant
|
||
// message at each index. Populated when a turn finishes streaming, used
|
||
// in MessageRow to show the right name in the signature line. Survives
|
||
// until the conversation is cleared.
|
||
const [messageAgents, setMessageAgents] = useState<Map<number, Agent>>(
|
||
() => new Map(),
|
||
)
|
||
|
||
// Hand-off prompt — only emitted when this conversation has been touched
|
||
// by more than one agent. Lists prior personas the new agent might see in
|
||
// the transcript.
|
||
const handoffNote = useMemo(() => {
|
||
if (agentHistory.size <= 1) return ""
|
||
if (!activeAgent || !agentHistory.has(activeAgent.id)) return ""
|
||
const others = [...agentHistory.values()].filter((a) => a.id !== activeAgent.id)
|
||
if (others.length === 0) return ""
|
||
const list = others
|
||
.map((a) => `• ${a.name} (${a.role})`)
|
||
.join("\n")
|
||
return [
|
||
"PRIOR HAND-OFF:",
|
||
"Earlier turns in this conversation were produced by other agent personas. Their responses appear in the transcript as assistant turns. Read them as context — they reflect a different voice and may have different style or focus — but answer the next message in your own voice as the current persona. Don't re-introduce yourself unless the user asks who they're talking to.",
|
||
`Prior personas in this thread:\n${list}`,
|
||
].join("\n\n")
|
||
}, [agentHistory, activeAgent])
|
||
|
||
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,
|
||
handoffNote,
|
||
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 } = 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])
|
||
|
||
// "Clear conversation" must drop three things in lockstep:
|
||
// 1. The in-memory messages (setMessages([])).
|
||
// 2. The persisted live snapshot (clearLive()).
|
||
// 3. The initialLive ref — otherwise on the next render or hook
|
||
// reconciliation, useChat's reset() would re-seed from the
|
||
// captured-at-mount initialMessages and the old conversation
|
||
// pops back. (This was the bug.)
|
||
// We deliberately don't call useChat's reset() here because reset
|
||
// restores to opts.initialMessages, which we want to be empty.
|
||
const resetAndClear = useCallback(() => {
|
||
initialLive.current = []
|
||
clearLive()
|
||
setMessages([])
|
||
setAgentHistory(new Map())
|
||
setMessageAgents(new Map())
|
||
}, [setMessages])
|
||
|
||
// 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)
|
||
|
||
// Maintain agent-history. Two triggers:
|
||
// 1. When a turn finishes streaming and at least one user/assistant
|
||
// pair exists, the *current* active agent has demonstrably been
|
||
// involved — add it.
|
||
// 2. When the operator switches the active agent and there are already
|
||
// messages in the thread, the *previous* agent was the one talking
|
||
// until that moment — add it (the new one will be added on its
|
||
// first turn finish).
|
||
// Also reset everything when the conversation is cleared.
|
||
useEffect(() => {
|
||
if (messages.length === 0) {
|
||
// Fresh thread — drop any stale history so empty-state behaves.
|
||
if (agentHistory.size > 0) setAgentHistory(new Map())
|
||
prevAgentRef.current = activeAgent
|
||
return
|
||
}
|
||
const prev = prevAgentRef.current
|
||
if (prev && prev.id !== activeAgent?.id) {
|
||
// Operator just switched. Lock in the prior agent so the new one
|
||
// sees it in the hand-off note.
|
||
setAgentHistory((m) => {
|
||
if (m.has(prev.id)) return m
|
||
const next = new Map(m)
|
||
next.set(prev.id, prev)
|
||
return next
|
||
})
|
||
}
|
||
prevAgentRef.current = activeAgent
|
||
// Also add the current agent if it has just produced something.
|
||
if (activeAgent && !agentHistory.has(activeAgent.id) && !isStreaming) {
|
||
const last = messages[messages.length - 1]
|
||
if (last?.role === "assistant" && last.content.trim()) {
|
||
setAgentHistory((m) => {
|
||
if (m.has(activeAgent.id)) return m
|
||
const next = new Map(m)
|
||
next.set(activeAgent.id, activeAgent)
|
||
return next
|
||
})
|
||
}
|
||
}
|
||
}, [activeAgent, messages, isStreaming, agentHistory])
|
||
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
|
||
|
||
// Stamp the agent that produced this turn so the UI signature is
|
||
// accurate even after the operator switches personas later. Stamps
|
||
// the *current* activeAgent — by definition the producer of the
|
||
// turn that just finished.
|
||
if (activeAgent) {
|
||
setMessageAgents((m) => {
|
||
if (m.get(lastIdx)?.id === activeAgent.id) return m
|
||
const next = new Map(m)
|
||
next.set(lastIdx, activeAgent)
|
||
return next
|
||
})
|
||
}
|
||
|
||
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}
|
||
// Use the agent stamped on this index when known, fall
|
||
// back to the active agent (covers the live stream
|
||
// before the post-stream effect fires).
|
||
agentName={
|
||
messageAgents.get(i)?.name ?? 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>
|
||
{/* base-ui's Menu.Trigger renders its own <button>, so we don't wrap
|
||
* a nested <button> here (which Radix's asChild pattern would require).
|
||
* Styles + data-action go straight on the Trigger. */}
|
||
<DropdownMenuTrigger
|
||
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" />
|
||
</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>
|
||
)
|
||
}
|