Sidenav (app-shell.tsx): - Each NavGroup now carries an icon (Building2 / Database / Plug / MessageSquare / Eye / Sparkles) rendered on the LEFT of the group header, with the chevron moved to the RIGHT. Header typography switched to caption + uppercase + tracking-wider muted, matching pristine-ui's main-branch app-shell. Same change applied to the mobile sheet's group headers. /ai mobile fixes (ai.tsx): - Composer container honors iOS safe-area inset (pb-[max(0.75rem,env(safe-area-inset-bottom))]) so the input clears the home indicator and stays above the soft keyboard. - Composer toolbar wraps on narrow viewports (flex-wrap + gap-y-1) so the agent / model / reasoning / voice chips don't clip. - Empty-state card uses px-4 sm:px-8 instead of hard px-8. - MessageRow's 56px turn-number gutter collapses below sm: prose flows full-width on phone, two-column layout returns at sm+. /ai desktop centering: - Console wrapper opts out of AppShell's [&>*:first-child]:lg:pr-72 (the page-header clearance for the floating top-right pill) via lg:!pr-0. The /ai surface has no top-right page-header controls, so the inherited padding was shifting the chat column ~144px left of the visible viewport center. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2374 lines
78 KiB
TypeScript
2374 lines
78 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react"
|
||
import { createPortal } from "react-dom"
|
||
import {
|
||
Archive,
|
||
ArrowRight,
|
||
BookmarkPlus,
|
||
ChevronDown,
|
||
Command as CommandIcon,
|
||
Copy,
|
||
Download,
|
||
FileText,
|
||
Loader2,
|
||
Mic,
|
||
MicOff,
|
||
Plus,
|
||
RefreshCw,
|
||
RotateCcw,
|
||
Sparkles,
|
||
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 { useToast } from "@crema/notification-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"
|
||
// Shape of a single hit returned by the `search_docs` tool. Defined here
|
||
// rather than imported from the lib because the tool wrapper in
|
||
// admin-tools.ts intentionally collapses the lib's `tags[]` back to a
|
||
// single `category` for tool-response stability — this type matches
|
||
// what the model actually sees.
|
||
type DocHit = {
|
||
id: string
|
||
title: string
|
||
sourcePath: string
|
||
category: string
|
||
excerpt: string
|
||
score: number
|
||
}
|
||
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 {
|
||
loadActiveReasoning,
|
||
saveActiveReasoning,
|
||
subscribeActiveReasoning,
|
||
type ReasoningEffort,
|
||
} from "~/lib/arcadia/llm-configs"
|
||
import { formatContextForPrompt } from "@crema/aifirst-ui/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>
|
||
}
|
||
|
||
// Trigger a server-side rebuild of an arcadia-search corpus. Reads the
|
||
// same KB URL + token resolution as the search_kb tool (see admin-tools.ts).
|
||
// Surfaces success/failure via the existing toast provider.
|
||
async function reindexKB(
|
||
corpus: string,
|
||
toast: ReturnType<typeof useToast>,
|
||
): Promise<void> {
|
||
const baseUrl =
|
||
(typeof window !== "undefined" &&
|
||
(window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) ||
|
||
"http://127.0.0.1:7800"
|
||
const token =
|
||
(typeof window !== "undefined" &&
|
||
window.sessionStorage.getItem("arcadia_access_token")) ||
|
||
"dev"
|
||
const url = `${baseUrl}/index/${encodeURIComponent(corpus)}/build`
|
||
// Use useToast's actual API (see lib-notification-ui): tone helpers
|
||
// success/error/info/warning, plus generic toast({ title, description, tone }).
|
||
toast.info(`Reindexing '${corpus}'…`, {
|
||
description: `POST ${url}`,
|
||
})
|
||
try {
|
||
const res = await fetch(url, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}: ${await res.text()}`)
|
||
}
|
||
const out = (await res.json()) as { chunk_count: number; built_at: string }
|
||
toast.success("Reindex complete", {
|
||
description: `${out.chunk_count} chunks indexed for '${corpus}'.`,
|
||
})
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
// eslint-disable-next-line no-console
|
||
console.error("[reindexKB]", err)
|
||
toast.error("Reindex failed", { description: msg })
|
||
}
|
||
}
|
||
|
||
// Synthetic assistant message that exercises every typed rich-output block.
|
||
// Wired to the "preview rich-output blocks" button in the empty state — used
|
||
// to eyeball renderer + theme without driving a live model. Safe to delete
|
||
// once Phase 2 has been validated end-to-end.
|
||
const BLOCK_SAMPLES_CONTENT = `Here's one example of every rich-output block, in roughly the order a model would emit them.
|
||
|
||
A **kpi** strip for headline numbers:
|
||
|
||
\`\`\`kpi
|
||
{ "items": [
|
||
{ "label": "Tenants", "value": 42 },
|
||
{ "label": "Active users", "value": 318, "unit": "/day" },
|
||
{ "label": "Suspended", "value": 4 },
|
||
{ "label": "Storage", "value": "1.2", "unit": "TB" }
|
||
] }
|
||
\`\`\`
|
||
|
||
A **table** for tabular data:
|
||
|
||
\`\`\`table
|
||
{ "columns": [
|
||
{ "id": "slug", "header": "Tenant" },
|
||
{ "id": "users", "header": "Users", "align": "right" },
|
||
{ "id": "status", "header": "Status" }
|
||
],
|
||
"rows": [
|
||
{ "slug": "acme", "users": 42, "status": "active" },
|
||
{ "slug": "globex", "users": 18, "status": "suspended" },
|
||
{ "slug": "initech", "users": 73, "status": "active" }
|
||
],
|
||
"idKey": "slug" }
|
||
\`\`\`
|
||
|
||
A **chart-bar** for category comparison and a **chart-line** for a trend:
|
||
|
||
\`\`\`chart-bar
|
||
{ "title": "Users by tenant",
|
||
"data": [
|
||
{ "label": "acme", "value": 42 },
|
||
{ "label": "globex", "value": 18 },
|
||
{ "label": "initech", "value": 73 },
|
||
{ "label": "umbrella", "value": 11 }
|
||
] }
|
||
\`\`\`
|
||
|
||
\`\`\`chart-line
|
||
{ "title": "Signups over time",
|
||
"series": [
|
||
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 },
|
||
{ "x": 4, "y": 31 }, { "x": 5, "y": 28 }, { "x": 6, "y": 42 }
|
||
] }
|
||
\`\`\`
|
||
|
||
A **chart-donut** for part-to-whole and a **chart-spark** inline:
|
||
|
||
\`\`\`chart-donut
|
||
{ "title": "Status breakdown",
|
||
"data": [
|
||
{ "label": "active", "value": 38 },
|
||
{ "label": "suspended", "value": 4 },
|
||
{ "label": "deactivated", "value": 2 }
|
||
] }
|
||
\`\`\`
|
||
|
||
\`\`\`chart-spark
|
||
{ "values": [3, 5, 4, 8, 12, 9, 14, 11, 18, 16, 22] }
|
||
\`\`\`
|
||
|
||
A **code** block and a **diff**:
|
||
|
||
\`\`\`code
|
||
{ "code": "SELECT slug, count(*) AS users\\nFROM tenants t\\nJOIN users u ON u.tenant_id = t.id\\nWHERE t.status = 'active'\\nGROUP BY slug\\nORDER BY users DESC;",
|
||
"language": "sql",
|
||
"title": "Active tenants by user count",
|
||
"lineNumbers": true }
|
||
\`\`\`
|
||
|
||
\`\`\`diff
|
||
{ "oldCode": "max_users: 100\\nplan: free\\n",
|
||
"newCode": "max_users: 250\\nplan: pro\\n",
|
||
"language": "yaml",
|
||
"title": "Tenant quota change",
|
||
"mode": "unified" }
|
||
\`\`\`
|
||
|
||
A **flowchart** for control flow and an **orgchart** for hierarchy:
|
||
|
||
\`\`\`flowchart
|
||
{ "nodes": [
|
||
{ "id": "a", "type": "start", "label": "Receive request", "x": 80, "y": 20 },
|
||
{ "id": "b", "type": "process", "label": "Validate token", "x": 80, "y": 110 },
|
||
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 80, "y": 200 },
|
||
{ "id": "d", "type": "process", "label": "Process", "x": 260, "y": 200 },
|
||
{ "id": "e", "type": "end", "label": "Reject (401)", "x": 80, "y": 310 }
|
||
],
|
||
"edges": [
|
||
{ "from": "a", "to": "b" },
|
||
{ "from": "b", "to": "c" },
|
||
{ "from": "c", "to": "d", "label": "yes" },
|
||
{ "from": "c", "to": "e", "label": "no" }
|
||
] }
|
||
\`\`\`
|
||
|
||
\`\`\`orgchart
|
||
{ "data": {
|
||
"id": "root", "name": "Platform", "title": "Tenant",
|
||
"children": [
|
||
{ "id": "a", "name": "Auth", "title": "Service",
|
||
"children": [
|
||
{ "id": "a1", "name": "Sessions", "title": "Module" },
|
||
{ "id": "a2", "name": "MFA", "title": "Module" }
|
||
] },
|
||
{ "id": "b", "name": "Billing", "title": "Service",
|
||
"children": [
|
||
{ "id": "b1", "name": "Invoices", "title": "Module" }
|
||
] }
|
||
] } }
|
||
\`\`\`
|
||
|
||
A **steps** trail for a multi-step plan:
|
||
|
||
\`\`\`steps
|
||
{ "steps": [
|
||
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
|
||
{ "id": "2", "title": "Filter suspended", "status": "running" },
|
||
{ "id": "3", "title": "Build report", "status": "queued" },
|
||
{ "id": "4", "title": "Email summary", "status": "queued" }
|
||
] }
|
||
\`\`\`
|
||
|
||
A **welcome** hero, a **checklist**, and a **hint**:
|
||
|
||
\`\`\`welcome
|
||
{ "title": "Welcome to Arcadia Admin",
|
||
"description": "Manage tenants, users, and platform settings from one place.",
|
||
"badge": "v2",
|
||
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
|
||
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
|
||
\`\`\`
|
||
|
||
\`\`\`checklist
|
||
{ "title": "Get started",
|
||
"description": "Finish setting up your tenant.",
|
||
"tasks": [
|
||
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
|
||
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
|
||
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
|
||
] }
|
||
\`\`\`
|
||
|
||
\`\`\`hint
|
||
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
|
||
\`\`\`
|
||
|
||
And the legacy **card** kinds — pill, stat, callout:
|
||
|
||
\`\`\`card
|
||
{ "kind": "pill", "status": "active", "label": "active" }
|
||
\`\`\`
|
||
|
||
\`\`\`card
|
||
{ "kind": "stat", "label": "MRR", "value": "$12.4k" }
|
||
\`\`\`
|
||
|
||
\`\`\`card
|
||
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "Suspending a tenant blocks all of its users immediately." }
|
||
\`\`\`
|
||
|
||
Clear the conversation to dismiss the preview.`
|
||
|
||
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.
|
||
}
|
||
}
|
||
/* Per-message agent attribution + the set of agents that have produced
|
||
* turns in the current conversation. Persisted alongside LIVE_KEY so a
|
||
* reload mid-thread preserves both who-said-what and the hand-off note
|
||
* the next turn carries.
|
||
*
|
||
* Stored as plain JSON shapes (Maps don't serialize):
|
||
* AGENTS_KEY: Array<[agentId, Agent]> ← agentHistory
|
||
* MSG_AGENTS_KEY: Array<[index, Agent]> ← messageAgents
|
||
*/
|
||
const AGENTS_KEY = "crema.ai.agent-history"
|
||
const MSG_AGENTS_KEY = "crema.ai.message-agents"
|
||
|
||
function loadAgentHistory(): Map<string, Agent> {
|
||
if (typeof window === "undefined") return new Map()
|
||
try {
|
||
const raw = localStorage.getItem(AGENTS_KEY)
|
||
if (!raw) return new Map()
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) return new Map(parsed as [string, Agent][])
|
||
} catch {}
|
||
return new Map()
|
||
}
|
||
function saveAgentHistory(m: Map<string, Agent>) {
|
||
if (typeof window === "undefined") return
|
||
if (m.size === 0) {
|
||
localStorage.removeItem(AGENTS_KEY)
|
||
return
|
||
}
|
||
try {
|
||
localStorage.setItem(AGENTS_KEY, JSON.stringify([...m.entries()]))
|
||
} catch {}
|
||
}
|
||
|
||
function loadMessageAgents(): Map<number, Agent> {
|
||
if (typeof window === "undefined") return new Map()
|
||
try {
|
||
const raw = localStorage.getItem(MSG_AGENTS_KEY)
|
||
if (!raw) return new Map()
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) return new Map(parsed as [number, Agent][])
|
||
} catch {}
|
||
return new Map()
|
||
}
|
||
function saveMessageAgents(m: Map<number, Agent>) {
|
||
if (typeof window === "undefined") return
|
||
if (m.size === 0) {
|
||
localStorage.removeItem(MSG_AGENTS_KEY)
|
||
return
|
||
}
|
||
try {
|
||
localStorage.setItem(MSG_AGENTS_KEY, JSON.stringify([...m.entries()]))
|
||
} catch {}
|
||
}
|
||
function clearAgentMaps() {
|
||
if (typeof window === "undefined") return
|
||
localStorage.removeItem(AGENTS_KEY)
|
||
localStorage.removeItem(MSG_AGENTS_KEY)
|
||
}
|
||
|
||
function clearLive() {
|
||
if (typeof window === "undefined") return
|
||
localStorage.removeItem(LIVE_KEY)
|
||
}
|
||
|
||
/* Per-conversation reasoning override. Cycle order matters — the composer
|
||
* chip walks this array. Storage helpers (load/save/subscribe) live in
|
||
* lib/arcadia/llm-configs.ts so the settings panel and the /ai composer
|
||
* coordinate via the same crema.ai.reasoning key. */
|
||
const REASONING_LEVELS: ReasoningEffort[] = ["off", "low", "medium", "high", "max"]
|
||
|
||
function withReasoning<T extends Record<string, unknown>>(
|
||
extras: T,
|
||
effort: ReasoningEffort,
|
||
): T & { reasoning_effort?: string } {
|
||
if (effort === "off") return extras
|
||
return { ...extras, reasoning_effort: effort }
|
||
}
|
||
|
||
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>
|
||
<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)] lg:!pr-0"
|
||
>
|
||
<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.
|
||
// Both maps are seeded from localStorage so a reload mid-thread keeps
|
||
// attribution + hand-off context intact. Persisted on every change via
|
||
// the effect lower down, cleared together with the live snapshot.
|
||
const [agentHistory, setAgentHistory] = useState<Map<string, Agent>>(
|
||
() => loadAgentHistory(),
|
||
)
|
||
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.
|
||
const [messageAgents, setMessageAgents] = useState<Map<number, Agent>>(
|
||
() => loadMessageAgents(),
|
||
)
|
||
|
||
// Persist whenever either map changes.
|
||
useEffect(() => {
|
||
saveAgentHistory(agentHistory)
|
||
}, [agentHistory])
|
||
useEffect(() => {
|
||
saveMessageAgents(messageAgents)
|
||
}, [messageAgents])
|
||
|
||
// 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,
|
||
formatContextForPrompt(),
|
||
]
|
||
.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()
|
||
clearAgentMaps()
|
||
setMessages([])
|
||
setAgentHistory(new Map())
|
||
setMessageAgents(new Map())
|
||
// Keep reasoningEffort as-is. It's bound to the active config's
|
||
// default (set when the operator stars a config in Settings) and
|
||
// resetting it would silently undo their intent on every clear.
|
||
}, [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)
|
||
// Mirror of reasoningEffort state, kept current via the effect below so
|
||
// regenerate/continue callbacks (declared before the state hook) can
|
||
// read the latest value without becoming reasoningEffort dependents.
|
||
const reasoningEffortRef = useRef<ReasoningEffort>("off")
|
||
|
||
// 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 = 6
|
||
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) {
|
||
// Cap reached. Synthesize a tool-error response for each pending
|
||
// call so the conversation stays valid (every assistant tool_calls
|
||
// turn must be followed by tool messages — otherwise the next
|
||
// request to DeepSeek 400s on "insufficient tool messages"). Then
|
||
// continue the chat so the model writes a final answer with what
|
||
// it has, or apologizes for not finishing.
|
||
const cappedMessages = calls.map((c) => ({
|
||
role: "tool" as const,
|
||
content: JSON.stringify({
|
||
error: `MAX_TOOL_ITERATIONS (${MAX_TOOL_ITERATIONS}) reached — please answer with what you have so far.`,
|
||
}),
|
||
toolCallId: c.id,
|
||
name: c.name,
|
||
}))
|
||
void continueChat(cappedMessages, {
|
||
system: systemPrompt,
|
||
tools: getOpenAITools(),
|
||
})
|
||
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 toast = useToast()
|
||
|
||
const copyMarkdown = useCallback(async () => {
|
||
if (messages.length === 0) return
|
||
try {
|
||
await navigator.clipboard.writeText(buildTranscript())
|
||
toast.success("Copied as Markdown", {
|
||
description: `${messages.length} message${messages.length === 1 ? "" : "s"} on the clipboard.`,
|
||
})
|
||
} catch {
|
||
toast.error("Couldn't copy", {
|
||
description: "Clipboard access was blocked.",
|
||
})
|
||
}
|
||
}, [buildTranscript, messages.length, toast])
|
||
|
||
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)
|
||
const filename = `ai-${stamp}.md`
|
||
a.download = filename
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
toast.success("Exported transcript", { description: filename })
|
||
}, [buildTranscript, messages.length, toast])
|
||
|
||
const saveToLibrary = useCallback(() => {
|
||
if (messages.length === 0) return
|
||
const md = buildTranscript()
|
||
const title =
|
||
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
|
||
"AI conversation"
|
||
addLibraryItem({
|
||
kind: "conversation",
|
||
title,
|
||
content: md,
|
||
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
|
||
agentName: activeAgent?.name,
|
||
agentRole: activeAgent?.role,
|
||
messageCount: messages.length,
|
||
})
|
||
toast.success("Saved to Library", { description: title })
|
||
}, [buildTranscript, messages, activeAgent, toast])
|
||
|
||
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, withReasoning({ tools: getOpenAITools() }, reasoningEffortRef.current)),
|
||
0,
|
||
)
|
||
}, [messages, setMessages, send, isStreaming])
|
||
|
||
const continueLast = useCallback(() => {
|
||
if (isStreaming || messages.length === 0) return
|
||
void send(
|
||
"Please continue your previous reply.",
|
||
withReasoning({ tools: getOpenAITools() }, reasoningEffortRef.current),
|
||
)
|
||
}, [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])
|
||
|
||
// Per-conversation reasoning override. Persists across page reloads via
|
||
// localStorage so the operator's chosen level survives a refresh, but
|
||
// resets when they clear the conversation. "off" = pass nothing through.
|
||
// Initialize from the shared key (settings panel writes this when the
|
||
// operator stars a config), persist on change, and live-subscribe so a
|
||
// star action in another tab/route updates the chip without a refresh.
|
||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||
() => loadActiveReasoning(),
|
||
)
|
||
useEffect(() => {
|
||
saveActiveReasoning(reasoningEffort)
|
||
reasoningEffortRef.current = reasoningEffort
|
||
}, [reasoningEffort])
|
||
useEffect(() => {
|
||
return subscribeActiveReasoning((next) => {
|
||
// Don't loop on our own writes — we already wrote `reasoningEffort`
|
||
// when it changed. Only pick up writes that disagree with state.
|
||
setReasoningEffort((cur) => (cur === next ? cur : next))
|
||
})
|
||
}, [])
|
||
|
||
const cycleReasoning = useCallback(() => {
|
||
setReasoningEffort((cur) => {
|
||
const idx = REASONING_LEVELS.indexOf(cur)
|
||
return REASONING_LEVELS[(idx + 1) % REASONING_LEVELS.length]
|
||
})
|
||
}, [])
|
||
|
||
const submit = useCallback(() => {
|
||
const text = input.trim()
|
||
if (!text || isStreaming) return
|
||
setInput("")
|
||
stickRef.current = true
|
||
void send(text, withReasoning({ tools: getOpenAITools() }, reasoningEffort))
|
||
}, [input, isStreaming, send, reasoningEffort])
|
||
|
||
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-[10%] px-4 transition-opacity duration-300 sm:px-8"
|
||
style={{ opacity: isEmpty ? 1 : 0 }}
|
||
>
|
||
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
||
<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>{" "}
|
||
<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 className="console-empty-line pointer-events-auto flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setMessages([
|
||
{ role: "assistant", content: BLOCK_SAMPLES_CONTENT } as LLMMessage,
|
||
])
|
||
}
|
||
className="console-mono inline-flex items-center gap-1.5 rounded-md border border-[var(--console-rule-soft)] bg-transparent px-2.5 py-1 text-[10.5px] uppercase tracking-[0.18em] text-[var(--console-muted)] transition-colors hover:border-[var(--console-amber)] hover:text-[var(--console-amber)]"
|
||
>
|
||
› preview rich-output blocks
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void reindexKB("docs", toast)}
|
||
className="console-mono inline-flex items-center gap-1.5 rounded-md border border-[var(--console-rule-soft)] bg-transparent px-2.5 py-1 text-[10.5px] uppercase tracking-[0.18em] text-[var(--console-muted)] transition-colors hover:border-[var(--console-amber)] hover:text-[var(--console-amber)]"
|
||
data-action="kb-reindex-docs"
|
||
>
|
||
› reindex kb (docs)
|
||
</button>
|
||
</div>
|
||
</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}
|
||
sources={
|
||
m.role === "assistant"
|
||
? extractDocSources(messages, i)
|
||
: undefined
|
||
}
|
||
/>
|
||
{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 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] 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…"}
|
||
reasoning={reasoningEffort}
|
||
onCycleReasoning={cycleReasoning}
|
||
/>
|
||
{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)
|
||
}
|
||
|
||
/** Walk forward from an assistant message and collect doc-search hits from
|
||
* any matching `tool` result messages. Deduped by sourcePath so a chunk and
|
||
* its sibling chunk in the same file collapse to one citation. */
|
||
function extractDocSources(
|
||
messages: LLMMessage[],
|
||
assistantIdx: number,
|
||
): DocHit[] {
|
||
const msg = messages[assistantIdx]
|
||
if (msg?.role !== "assistant" || !msg.toolCalls?.length) return []
|
||
const docCallIds = new Set(
|
||
msg.toolCalls.filter((tc) => tc.name === "search_docs").map((tc) => tc.id),
|
||
)
|
||
if (docCallIds.size === 0) return []
|
||
|
||
const seen = new Set<string>()
|
||
const out: DocHit[] = []
|
||
for (let i = assistantIdx + 1; i < messages.length; i++) {
|
||
const m = messages[i]
|
||
if (m.role === "assistant") break // hit the next turn
|
||
if (m.role !== "tool" || !m.toolCallId || !docCallIds.has(m.toolCallId)) {
|
||
continue
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(m.content) as { hits?: DocHit[] }
|
||
for (const h of parsed.hits ?? []) {
|
||
if (seen.has(h.sourcePath + "#" + h.id)) continue
|
||
seen.add(h.sourcePath + "#" + h.id)
|
||
out.push(h)
|
||
}
|
||
} catch {
|
||
// Tool errors come back as { error } — no hits to surface.
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
function SourcesFooter({ sources }: { sources: DocHit[] }) {
|
||
if (sources.length === 0) return null
|
||
return (
|
||
<div className="console-mono mt-3 flex flex-wrap items-center gap-1.5 text-[10.5px] tracking-[0.08em] text-[var(--console-muted)]">
|
||
<span className="uppercase text-[var(--console-muted-2)]">sources</span>
|
||
<span className="text-[var(--console-muted-2)]">›</span>
|
||
{sources.map((s) => (
|
||
<span
|
||
key={s.id}
|
||
title={`${s.sourcePath}\n\n${s.excerpt}`}
|
||
className="inline-flex max-w-[28ch] items-center gap-1 truncate rounded border border-[var(--console-rule-soft)] bg-[var(--console-deck)] px-1.5 py-0.5 text-[var(--console-text-2)]"
|
||
>
|
||
<span className="truncate">{s.title}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MessageRow({
|
||
role,
|
||
content,
|
||
toolCalls,
|
||
turnNum,
|
||
agentName,
|
||
timestamp,
|
||
sources,
|
||
}: {
|
||
role: "user" | "assistant"
|
||
content: string
|
||
toolCalls?: ToolCall[]
|
||
turnNum?: number
|
||
agentName?: string
|
||
timestamp?: string
|
||
sources?: DocHit[]
|
||
}) {
|
||
// 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-1 gap-x-3 self-stretch sm:grid-cols-[3.5rem_1fr]">
|
||
<div className="hidden flex-col items-end pt-[3px] sm:flex">
|
||
<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="sm:border-l sm:border-[var(--console-rule-soft)] sm: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-1 gap-x-3 self-stretch sm:grid-cols-[3.5rem_1fr]">
|
||
<div className="hidden flex-col items-end pt-[2px] sm:flex">
|
||
<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="sm:border-l sm:border-[var(--console-cyan-deep)]/40 sm:pl-4">
|
||
<div className="console-agent-prose">
|
||
<MessageBody content={content} toolCalls={toolCalls} />
|
||
</div>
|
||
{sources && sources.length > 0 && <SourcesFooter sources={sources} />}
|
||
<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,
|
||
reasoning,
|
||
onCycleReasoning,
|
||
}: {
|
||
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
|
||
reasoning: ReasoningEffort
|
||
onCycleReasoning: () => void
|
||
}) {
|
||
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 flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||
<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}
|
||
/>
|
||
<ReasoningChip value={reasoning} onCycle={onCycleReasoning} />
|
||
<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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Reasoning-effort chip for the composer. Click cycles off → low → medium →
|
||
* high → max → off. When non-off, the next send includes
|
||
* `reasoning_effort: <level>` which the proxy passes to OpenAI/DeepSeek
|
||
* natively and translates to Anthropic's thinking block server-side.
|
||
*
|
||
* Visually: hidden when off (no chrome clutter for the common case),
|
||
* surfaces as a sodium-amber pill when set.
|
||
*/
|
||
function ReasoningChip({
|
||
value,
|
||
onCycle,
|
||
}: {
|
||
value: ReasoningEffort
|
||
onCycle: () => void
|
||
}) {
|
||
const active = value !== "off"
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onCycle}
|
||
data-action="ai-reasoning"
|
||
title={
|
||
active
|
||
? `Reasoning: ${value}. Click to cycle.`
|
||
: "Reasoning: off. Click to enable thinking mode."
|
||
}
|
||
className={[
|
||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-mono uppercase tracking-[0.12em] transition-colors",
|
||
active
|
||
? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 dark:text-amber-300"
|
||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||
].join(" ")}
|
||
>
|
||
<Sparkles className="size-3" />
|
||
<span className="select-none">
|
||
think
|
||
{active ? <span className="ml-1 font-semibold">{value}</span> : null}
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
// Close the popover after a tile is clicked, so the menu acknowledges the
|
||
// action visually even when the action itself produces no obvious change
|
||
// (Copy MD, Export MD, etc — those also fire a toast at the call site).
|
||
const close = useCallback(
|
||
(fn: () => void) => () => {
|
||
fn()
|
||
setOpen(false)
|
||
},
|
||
[],
|
||
)
|
||
return (
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<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={close(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={close(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={close(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={close(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={close(onCopyMarkdown)}
|
||
disabled={!hasMessages}
|
||
icon={<Copy className="size-4" />}
|
||
label="Copy MD"
|
||
title="Copy conversation as Markdown"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-export-md"
|
||
onClick={close(onExportMarkdown)}
|
||
disabled={!hasMessages}
|
||
icon={<Download className="size-4" />}
|
||
label="Export MD"
|
||
title="Download a .md file"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-save-library"
|
||
onClick={close(onSaveToLibrary)}
|
||
disabled={!hasMessages}
|
||
icon={<BookmarkPlus className="size-4" />}
|
||
label="Save to Library"
|
||
title="Snapshot this conversation"
|
||
/>
|
||
<ToolTile
|
||
data-action="ai-cmd-show-prompt"
|
||
onClick={close(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={close(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={close(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 {}
|
||
}
|
||
if (typeof document === "undefined") return null
|
||
return createPortal(
|
||
<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>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|