Arcadia wiring: - home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context - profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits - session: drop unused signIn mock, add updateSessionUser, refresh tests - profile schema: drop redundant Profile.name/email (session is the source of truth) - routes: delete orphaned resources route + lib Auth flows that previously 404'd: - /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui - shared AuthShell + AuthBrand wrapper Assistant tools (admin-tools.ts): - +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role - list_memberships gains user_id filter for "tenants this user belongs to" queries - search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used UI consistency: - new PageHeader component - AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content - removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects) - stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6) - migrated home + tenants to PageHeader arcadia-search ergonomics: - scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local - README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs - .env.local now gitignored Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2317 lines
82 KiB
TypeScript
2317 lines
82 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import {
|
||
RefreshCw,
|
||
Square,
|
||
Archive,
|
||
Loader2,
|
||
MoreHorizontal,
|
||
Wand2,
|
||
Trash2,
|
||
RotateCcw,
|
||
ArrowRight,
|
||
Undo2,
|
||
Copy,
|
||
Download,
|
||
FileText,
|
||
Pin,
|
||
Pencil,
|
||
Volume2,
|
||
VolumeX,
|
||
Mic,
|
||
MicOff,
|
||
Plus,
|
||
MessagesSquare,
|
||
BookmarkPlus,
|
||
Users,
|
||
} from "lucide-react"
|
||
|
||
const PROBE_TIMEOUT_MS = 3000
|
||
|
||
// Static catalog of every data-action id wired into the app.
|
||
// "Available actions" in the system prompt only lists what's on screen NOW;
|
||
// this catalog tells the model what exists elsewhere so it can plan
|
||
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
||
// Rich-output protocol: typed fenced blocks the chat renderer turns into UI
|
||
// from @crema/*-ui. The system prompt only carries a thin INDEX (kind →
|
||
// one-line purpose) — full schemas live in app/lib/block-schemas.ts and are
|
||
// fetched on demand via the get_block_schema tool. Adding a new block kind
|
||
// = edit block-schemas.ts + the renderer; no prompt edit required.
|
||
import { blockIndexForPrompt } from "~/lib/block-schemas"
|
||
const RICH_OUTPUT_PREFACE = blockIndexForPrompt()
|
||
|
||
|
||
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
|
||
|
||
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
||
|
||
Rules:
|
||
- Prefer \`click nav-<page>\` (e.g. \`click nav-settings\`) over \`navigate <path>\` so the user sees the cursor travel to the sidebar.
|
||
- When a step lands on a new page, follow it with \`wait_for <id>\` for an element on that page before the next step (it will appear once the page mounts).
|
||
- Combine multi-step requests into ONE action block. Quote string values.
|
||
- ALWAYS end an action block by returning the user to the Assistant page so the conversation continues there. Append \`click nav-assistant\` as the final step (and \`wait_for assistant-ui-control\` after it) UNLESS the task itself is on the Assistant page.
|
||
- Prefer doing over describing. Keep prose to one short sentence; put commands in the block.
|
||
|
||
Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet):
|
||
|
||
Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||
Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar
|
||
Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout
|
||
Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset
|
||
Overview tiles: home-tile-assistant, home-tile-resources, home-tile-activity, home-tile-library
|
||
Settings page: settings-base-url (input, fill with URL), settings-context-tokens (number input), settings-response-budget (number input), settings-system-prompt (textarea), settings-system-prompt-reset, settings-save, settings-test, settings-reset
|
||
Settings sub-nav: settings-section-llm, settings-section-agents, settings-section-appearance, settings-section-account, settings-section-about
|
||
Agents settings: settings-agent-new, settings-agent-reset, settings-agent-activate-<id>, settings-agent-edit-<id>, settings-agent-delete-<id>, settings-agent-name-<id>, settings-agent-role-<id>, settings-agent-prompt-<id>
|
||
Assistant agent picker: assistant-agent (dropdown — click to switch persona)
|
||
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
|
||
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
||
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
||
Tenants page: tenants-refresh, tenants-search (input), tenants-create. Per-row (use the tenant's slug — see the "tenants" surface in Admin context for available slugs): tenant-<slug>-actions (open the kebab first), tenant-<slug>-suspend, tenant-<slug>-activate, tenant-<slug>-deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant-<slug>-actions, wait_for tenant-<slug>-suspend, click tenant-<slug>-suspend.
|
||
Login page: login-email, login-password, login-submit
|
||
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
||
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
||
fill notif-title "Reminder"
|
||
fill notif-body "Take a 5-minute break"
|
||
fill notif-kind "info" # info | success | warning | error
|
||
fill notif-href "/library" # optional, omit if no link
|
||
click notif-create
|
||
Use this any time the user asks you to remind them, leave a note, flag something, or queue an item — drop a notification.
|
||
|
||
Example — User: "Go to settings and set the response cap to 1024" →
|
||
"On it.
|
||
|
||
\`\`\`action
|
||
click nav-settings
|
||
wait_for settings-response-budget
|
||
fill settings-response-budget "1024"
|
||
click settings-save
|
||
click nav-assistant
|
||
wait_for assistant-ui-control
|
||
\`\`\`"`
|
||
|
||
|
||
import { formatAdminContextForPrompt } from "~/lib/admin-context"
|
||
import {
|
||
buildDenialMessages,
|
||
classifyCalls,
|
||
getOpenAITools,
|
||
runLLMToolCalls,
|
||
} from "~/lib/admin-tools"
|
||
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
|
||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||
import { ConfirmCard } from "~/components/assistant/confirm-card"
|
||
import type { ToolCall } from "@crema/llm-ui"
|
||
|
||
/**
|
||
* Always includes domain knowledge + tools + admin context + persona.
|
||
* Adds the UI-control DSL/action catalog only when uiControl is on (those rules
|
||
* are about driving the cursor, irrelevant when the assistant is in plain Q&A).
|
||
*/
|
||
function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean): string {
|
||
const persona = activeAgent
|
||
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||
: ""
|
||
const ctx = formatAdminContextForPrompt()
|
||
const parts = [
|
||
"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.",
|
||
"Two retrieval surfaces exist for documentation/knowledge: `search_docs` (browser-side, BM25 over the bundled arcadia docs — fast, always available, small corpus) and `search_kb` (server-side, BM25 over arcadia-search — same docs as `corpus=docs` for parity, plus larger and additional corpora as the operator adds them). For questions about the bundled arcadia docs either is fine; prefer `search_kb` when you want richer hits or when the user is asking about content that wouldn't be in the bundled docs (uploaded files, tenant-specific knowledge). When `search_kb` returns a chunk_id you want to expand, call `read_chunk(chunk_id, corpus)`.",
|
||
RICH_OUTPUT_PREFACE,
|
||
ARCADIA_KNOWLEDGE,
|
||
persona,
|
||
ctx,
|
||
]
|
||
if (uiControl) parts.push(UI_CONTROL_PREFACE)
|
||
return parts.filter(Boolean).join("\n\n")
|
||
}
|
||
|
||
function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
|
||
return new Promise<T>((resolve, reject) => {
|
||
const t = setTimeout(() => {
|
||
reject(new Error(`timeout after ${ms}ms`))
|
||
}, ms)
|
||
signal.addEventListener("abort", () => {
|
||
clearTimeout(t)
|
||
reject(new DOMException("Aborted", "AbortError"))
|
||
})
|
||
p.then(
|
||
(v) => {
|
||
clearTimeout(t)
|
||
resolve(v)
|
||
},
|
||
(e) => {
|
||
clearTimeout(t)
|
||
reject(e)
|
||
},
|
||
)
|
||
})
|
||
}
|
||
import {
|
||
LLMProvider,
|
||
MockLLM,
|
||
listModels,
|
||
useChat,
|
||
useCompletion,
|
||
type LLMAdapter,
|
||
} from "@crema/llm-ui"
|
||
import { TypingIndicator } from "@crema/chat-ui"
|
||
import { CommandBar } from "@crema/aifirst-ui"
|
||
|
||
import { AppShell } from "~/components/layout/app-shell"
|
||
import { MessageBody } from "~/components/assistant/message-body"
|
||
import { Button } from "~/components/ui/button"
|
||
import {
|
||
buildSystemPrompt,
|
||
estimateTokens,
|
||
runActionBlocks,
|
||
trimMessages,
|
||
} from "@crema/action-bus"
|
||
import {
|
||
buildAdapter,
|
||
getProvider,
|
||
useSettings as useProviderSettings,
|
||
} from "@crema/llm-providers-ui"
|
||
import {
|
||
composeSystemPrompt,
|
||
loadActiveAgentId,
|
||
saveActiveAgentId,
|
||
useAgents,
|
||
type Agent,
|
||
} from "~/lib/agents"
|
||
import {
|
||
clearThreadSnapshot,
|
||
createThread,
|
||
deleteThread,
|
||
deriveTitleFromFirstMessage,
|
||
ensureThread,
|
||
loadThreadSnapshot,
|
||
saveActiveThreadId,
|
||
snapshotThread,
|
||
updateThread,
|
||
useThreads,
|
||
type Thread,
|
||
type ThreadMessage,
|
||
} from "~/lib/threads"
|
||
import { addLibraryItem } from "~/lib/library"
|
||
import { pageTitle } from "~/lib/page-meta"
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "~/components/ui/dropdown-menu"
|
||
import { ChevronDown } from "lucide-react"
|
||
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "~/components/ui/popover"
|
||
|
||
export const meta = () => pageTitle("Assistant")
|
||
|
||
const STORAGE_KEY = "crema.assistant.model"
|
||
const UI_CONTROL_KEY = "crema.assistant.uiControl"
|
||
type StoredMessage = { role: "user" | "assistant"; content: string }
|
||
|
||
type PendingAction = { kind: "retry"; text: string } | null
|
||
|
||
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: [
|
||
{
|
||
matches: (req) =>
|
||
/hello|hi\b|hey/i.test(req.messages.at(-1)?.content ?? ""),
|
||
response:
|
||
"Hi — I'm the mock assistant. Try asking me anything; I'll stream a generic reply.",
|
||
},
|
||
{
|
||
matches: (req) =>
|
||
/(take me to|open|navigate|go to).*(tenants|users|library|settings|activity|assistant|overview|home)/i.test(
|
||
req.messages.at(-1)?.content ?? "",
|
||
),
|
||
response: [
|
||
"On it.\n\n",
|
||
"```action\n",
|
||
"navigate /tenants\n",
|
||
"```\n",
|
||
],
|
||
},
|
||
],
|
||
})
|
||
|
||
export default function AssistantRoute() {
|
||
const settings = useProviderSettings()
|
||
const arcadia = useArcadiaClient()
|
||
const provider = getProvider(settings.providerId)
|
||
const agents = useAgents()
|
||
const threads = useThreads()
|
||
const [status, setStatus] = useState<Status>({ kind: "probing" })
|
||
const [model, setModel] = useState<string>(() => {
|
||
if (typeof window === "undefined") return "mock"
|
||
return localStorage.getItem(STORAGE_KEY) ?? ""
|
||
})
|
||
const [compactNonce, setCompactNonce] = useState(0)
|
||
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
|
||
const remount = useCallback(() => setCompactNonce((n) => n + 1), [])
|
||
|
||
// Ensure at least one thread exists, and resolve the active one.
|
||
const fallbackAgentId =
|
||
loadActiveAgentId() || agents[0]?.id || ""
|
||
const ensured = useMemo(
|
||
() => ensureThread(threads, fallbackAgentId),
|
||
[threads, fallbackAgentId],
|
||
)
|
||
const activeThreadId = ensured.activeId
|
||
const activeThread =
|
||
ensured.threads.find((t) => t.id === activeThreadId) ?? ensured.threads[0]
|
||
|
||
const switchThread = useCallback(
|
||
(id: string) => {
|
||
saveActiveThreadId(id)
|
||
remount()
|
||
},
|
||
[remount],
|
||
)
|
||
|
||
const newConversation = useCallback(() => {
|
||
const agentId = activeThread?.agentId || fallbackAgentId
|
||
createThread(agentId)
|
||
remount()
|
||
}, [activeThread, fallbackAgentId, remount])
|
||
|
||
const removeThread = useCallback(
|
||
(id: string) => {
|
||
deleteThread(id)
|
||
remount()
|
||
},
|
||
[remount],
|
||
)
|
||
|
||
const renameThread = useCallback((id: string, title: string) => {
|
||
updateThread(id, { title })
|
||
}, [])
|
||
|
||
const [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
|
||
|
||
// When the user switches providers in /settings, follow.
|
||
useEffect(() => {
|
||
if (settings.model) setModel(settings.model)
|
||
}, [settings.providerId, settings.model])
|
||
|
||
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 () => {
|
||
try {
|
||
const a = await buildAdapter({
|
||
settings,
|
||
resolveSecret,
|
||
arcadiaBaseURL,
|
||
arcadiaAuthToken,
|
||
arcadiaTenantId,
|
||
})
|
||
setAdapter(a)
|
||
} catch {
|
||
setAdapter(mockAdapter)
|
||
}
|
||
|
||
// Anthropic has no /v1/models endpoint — use the catalog defaults.
|
||
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 {}
|
||
}
|
||
|
||
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 (err: unknown) {
|
||
if ((err as DOMException)?.name === "AbortError") return
|
||
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: err instanceof Error ? err.message : "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 && model !== "mock") localStorage.setItem(STORAGE_KEY, model)
|
||
}, [model])
|
||
|
||
const activeModel =
|
||
status.kind === "live" ? model || status.models[0] : "mock"
|
||
|
||
return (
|
||
<AppShell>
|
||
<LLMProvider adapter={adapter} model={activeModel}>
|
||
<AssistantSurface
|
||
key={`${activeThreadId}-${compactNonce}`}
|
||
thread={activeThread}
|
||
threads={ensured.threads}
|
||
agents={agents}
|
||
status={status}
|
||
model={model}
|
||
onModelChange={setModel}
|
||
contextTokens={settings.contextTokens}
|
||
responseBudget={settings.responseBudget}
|
||
baseURL={settings.baseURL || provider.baseURL}
|
||
basePrompt={settings.systemPrompt}
|
||
onRetryProbe={probe}
|
||
onRemount={remount}
|
||
pendingAction={pendingAction}
|
||
onPendingActionConsumed={() => setPendingAction(null)}
|
||
onRequestRetry={(text) => {
|
||
setPendingAction({ kind: "retry", text })
|
||
remount()
|
||
}}
|
||
onSwitchThread={switchThread}
|
||
onNewThread={newConversation}
|
||
onDeleteThread={removeThread}
|
||
onRenameThread={renameThread}
|
||
/>
|
||
</LLMProvider>
|
||
</AppShell>
|
||
)
|
||
}
|
||
|
||
function AssistantSurface({
|
||
status,
|
||
model,
|
||
onModelChange,
|
||
contextTokens,
|
||
responseBudget,
|
||
baseURL,
|
||
basePrompt,
|
||
onRetryProbe,
|
||
onRemount,
|
||
pendingAction,
|
||
onPendingActionConsumed,
|
||
onRequestRetry,
|
||
thread,
|
||
threads,
|
||
agents,
|
||
onSwitchThread,
|
||
onNewThread,
|
||
onDeleteThread,
|
||
onRenameThread,
|
||
}: {
|
||
status: Status
|
||
model: string
|
||
onModelChange: (m: string) => void
|
||
contextTokens: number
|
||
responseBudget: number
|
||
baseURL: string
|
||
basePrompt: string
|
||
onRetryProbe: () => void
|
||
onRemount: () => void
|
||
pendingAction: PendingAction
|
||
onPendingActionConsumed: () => void
|
||
onRequestRetry: (text: string) => void
|
||
thread: Thread
|
||
threads: Thread[]
|
||
agents: Agent[]
|
||
onSwitchThread: (id: string) => void
|
||
onNewThread: () => void
|
||
onDeleteThread: (id: string) => void
|
||
onRenameThread: (id: string, title: string) => void
|
||
}) {
|
||
// Active agent comes from the thread itself (per-thread persona).
|
||
const activeAgentId = thread.agentId || agents[0]?.id || ""
|
||
const setActiveAgentId = (id: string) => {
|
||
updateThread(thread.id, { agentId: id })
|
||
saveActiveAgentId(id)
|
||
}
|
||
const activeAgent: Agent | undefined =
|
||
agents.find((a) => a.id === activeAgentId) ?? agents[0]
|
||
const systemPrompt = composeSystemPrompt(basePrompt, activeAgent)
|
||
const [uiControl, setUiControl] = useState<boolean>(() => {
|
||
if (typeof window === "undefined") return false
|
||
return localStorage.getItem(UI_CONTROL_KEY) === "1"
|
||
})
|
||
const [actionLog, setActionLog] = useState<string | null>(null)
|
||
const [showUiControlBanner, setShowUiControlBanner] = useState(false)
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(UI_CONTROL_KEY, uiControl ? "1" : "0")
|
||
}, [uiControl])
|
||
|
||
useEffect(() => {
|
||
if (!uiControl) {
|
||
setShowUiControlBanner(false)
|
||
return
|
||
}
|
||
setShowUiControlBanner(true)
|
||
const t = setTimeout(() => setShowUiControlBanner(false), 6000)
|
||
return () => clearTimeout(t)
|
||
}, [uiControl])
|
||
|
||
// Always-on admin system prompt — domain primer + tools + persona, regardless
|
||
// of UI Control. Per-call `send(text, {system})` overrides may not always be
|
||
// honored across hook re-renders, so anchor it at construction too.
|
||
const constructorSystemPrompt = buildSystemPrompt({
|
||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||
preface: buildAdminPreface(activeAgent, uiControl),
|
||
includeActions: uiControl,
|
||
})
|
||
|
||
const { messages, send, continueChat, abort, isStreaming, error, reset } = useChat({
|
||
system: constructorSystemPrompt,
|
||
initialMessages: thread.messages as StoredMessage[],
|
||
})
|
||
|
||
// Persist conversation back into the active thread.
|
||
// Preserve any agentId already stamped on prior messages, and stamp newly
|
||
// appended assistant messages with the *currently active* agent.
|
||
useEffect(() => {
|
||
if (isStreaming) return
|
||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||
const prior = thread.messages[i]
|
||
if (m.role === "user") return { role: "user", content: m.content }
|
||
if (m.role === "tool") {
|
||
return {
|
||
role: "tool",
|
||
content: m.content,
|
||
toolCallId: m.toolCallId,
|
||
name: m.name,
|
||
}
|
||
}
|
||
const agentId = prior?.agentId ?? activeAgentId
|
||
return {
|
||
role: "assistant",
|
||
content: m.content,
|
||
agentId,
|
||
...(m.toolCalls ? { toolCalls: m.toolCalls } : {}),
|
||
}
|
||
})
|
||
updateThread(thread.id, {
|
||
messages: stamped,
|
||
...(thread.title === "New conversation" &&
|
||
messages[0]?.role === "user"
|
||
? { title: deriveTitleFromFirstMessage(messages[0].content) }
|
||
: {}),
|
||
})
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [messages, isStreaming])
|
||
|
||
const clearConversation = useCallback(() => {
|
||
updateThread(thread.id, { messages: [], pinned: [] })
|
||
onRemount()
|
||
}, [thread.id, onRemount])
|
||
|
||
const { complete: completeOneShot, isLoading: compacting } = useCompletion()
|
||
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: Math.min(800, responseBudget * 2) },
|
||
)
|
||
const condensed: StoredMessage[] = [
|
||
{
|
||
role: "assistant",
|
||
content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`,
|
||
},
|
||
]
|
||
// Snapshot the pre-compact thread so Restore can undo this.
|
||
snapshotThread(thread.id)
|
||
// Compact preserves pinned messages verbatim AFTER the summary line.
|
||
const pinned = thread.pinned
|
||
.map((idx) => messages[idx])
|
||
.filter(Boolean) as StoredMessage[]
|
||
const next: ThreadMessage[] = [
|
||
...condensed,
|
||
...pinned.map((m) => ({ ...m })),
|
||
]
|
||
updateThread(thread.id, {
|
||
messages: next,
|
||
pinned: pinned.map((_, i) => condensed.length + i),
|
||
})
|
||
setActionLog(`Compacted ${messages.length} turns → 1 summary.`)
|
||
onRemount()
|
||
} catch (e) {
|
||
setActionLog(
|
||
`Compact failed: ${e instanceof Error ? e.message : String(e)}`,
|
||
)
|
||
}
|
||
}, [
|
||
compacting,
|
||
isStreaming,
|
||
messages,
|
||
completeOneShot,
|
||
responseBudget,
|
||
onRemount,
|
||
])
|
||
|
||
const restoreCompact = useCallback(() => {
|
||
const snap = loadThreadSnapshot(thread.id)
|
||
if (!snap) {
|
||
setActionLog("Nothing to restore.")
|
||
return
|
||
}
|
||
updateThread(thread.id, {
|
||
messages: snap.messages,
|
||
pinned: snap.pinned,
|
||
})
|
||
clearThreadSnapshot(thread.id)
|
||
setActionLog(`Restored ${snap.messages.length} turns.`)
|
||
onRemount()
|
||
}, [thread.id, onRemount])
|
||
|
||
const regenerateLast = useCallback(() => {
|
||
// Find last user message; remove it + everything after; let parent
|
||
// re-mount us with that text as a pending retry.
|
||
let lastUserIdx = -1
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].role === "user") {
|
||
lastUserIdx = i
|
||
break
|
||
}
|
||
}
|
||
if (lastUserIdx === -1) {
|
||
setActionLog("No user message to regenerate.")
|
||
return
|
||
}
|
||
const text = messages[lastUserIdx].content
|
||
const truncated = messages.slice(0, lastUserIdx) as ThreadMessage[]
|
||
updateThread(thread.id, {
|
||
messages: truncated,
|
||
pinned: thread.pinned.filter((idx) => idx < lastUserIdx),
|
||
})
|
||
onRequestRetry(text)
|
||
}, [messages, onRequestRetry, thread.id, thread.pinned])
|
||
|
||
const handleSendRef = useRef<((t: string) => void) | null>(null)
|
||
const arcadia = useArcadiaClient()
|
||
const toolIterationsRef = useRef(0)
|
||
const processedTurnRef = useRef(-1)
|
||
const prevStreamingRef = useRef(isStreaming)
|
||
const MAX_TOOL_ITERATIONS = 3
|
||
|
||
// 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 [pendingConfirm, setPendingConfirm] = useState<{
|
||
afterIndex: number
|
||
writes: ToolCall[]
|
||
readMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
|
||
} | null>(null)
|
||
const [confirmBusy, setConfirmBusy] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const justFinished = prevStreamingRef.current && !isStreaming
|
||
prevStreamingRef.current = isStreaming
|
||
if (!justFinished) return
|
||
|
||
const lastIdx = messages.length - 1
|
||
if (lastIdx < 0) return
|
||
const last = messages[lastIdx]
|
||
if (last.role !== "assistant") return
|
||
if (processedTurnRef.current === lastIdx) return
|
||
processedTurnRef.current = lastIdx
|
||
|
||
const calls = last.toolCalls ?? []
|
||
if (calls.length === 0) {
|
||
toolIterationsRef.current = 0
|
||
return
|
||
}
|
||
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) {
|
||
console.warn("[admin-tools] tool-iteration cap reached, dropping calls", calls)
|
||
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: constructorSystemPrompt,
|
||
tools: getOpenAITools(),
|
||
maxTokens: responseBudget,
|
||
})
|
||
})()
|
||
}, [messages, isStreaming, arcadia, continueChat, constructorSystemPrompt, responseBudget])
|
||
|
||
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: constructorSystemPrompt,
|
||
tools: getOpenAITools(),
|
||
maxTokens: responseBudget,
|
||
})
|
||
} finally {
|
||
setPendingConfirm(null)
|
||
setConfirmBusy(false)
|
||
}
|
||
}, [pendingConfirm, arcadia, continueChat, constructorSystemPrompt, responseBudget])
|
||
|
||
const onDenyWrites = useCallback(() => {
|
||
if (!pendingConfirm) return
|
||
const denials = buildDenialMessages(pendingConfirm.writes)
|
||
void continueChat([...pendingConfirm.readMessages, ...denials], {
|
||
system: constructorSystemPrompt,
|
||
tools: getOpenAITools(),
|
||
maxTokens: responseBudget,
|
||
})
|
||
setPendingConfirm(null)
|
||
}, [pendingConfirm, continueChat, constructorSystemPrompt, responseBudget])
|
||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||
const [editingDraft, setEditingDraft] = useState("")
|
||
const [speakingIndex, setSpeakingIndex] = useState<number | null>(null)
|
||
const pinnedSet = useMemo(() => new Set(thread.pinned), [thread.pinned])
|
||
|
||
const togglePin = useCallback(
|
||
(i: number) => {
|
||
const next = pinnedSet.has(i)
|
||
? thread.pinned.filter((x) => x !== i)
|
||
: [...thread.pinned, i].sort((a, b) => a - b)
|
||
updateThread(thread.id, { pinned: next })
|
||
},
|
||
[pinnedSet, thread.id, thread.pinned],
|
||
)
|
||
|
||
const beginEdit = useCallback(
|
||
(i: number, content: string) => {
|
||
setEditingIndex(i)
|
||
setEditingDraft(content)
|
||
},
|
||
[],
|
||
)
|
||
|
||
const cancelEdit = useCallback(() => {
|
||
setEditingIndex(null)
|
||
setEditingDraft("")
|
||
}, [])
|
||
|
||
const submitEdit = useCallback(() => {
|
||
if (editingIndex === null) return
|
||
const text = editingDraft.trim()
|
||
if (!text) return
|
||
const truncated = messages.slice(0, editingIndex) as ThreadMessage[]
|
||
updateThread(thread.id, {
|
||
messages: truncated,
|
||
pinned: thread.pinned.filter((idx) => idx < editingIndex),
|
||
})
|
||
onRequestRetry(text)
|
||
}, [editingIndex, editingDraft, messages, thread.id, thread.pinned, onRequestRetry])
|
||
|
||
const speak = useCallback(
|
||
(i: number, text: string) => {
|
||
if (typeof window === "undefined" || !window.speechSynthesis) return
|
||
if (speakingIndex === i) {
|
||
window.speechSynthesis.cancel()
|
||
setSpeakingIndex(null)
|
||
return
|
||
}
|
||
window.speechSynthesis.cancel()
|
||
const u = new SpeechSynthesisUtterance(text)
|
||
u.onend = () => setSpeakingIndex((cur) => (cur === i ? null : cur))
|
||
u.onerror = () => setSpeakingIndex(null)
|
||
window.speechSynthesis.speak(u)
|
||
setSpeakingIndex(i)
|
||
},
|
||
[speakingIndex],
|
||
)
|
||
const continueLast = useCallback(() => {
|
||
if (isStreaming) return
|
||
handleSendRef.current?.("Please continue your previous reply.")
|
||
}, [isStreaming])
|
||
|
||
const [showPromptOpen, setShowPromptOpen] = useState(false)
|
||
const [hasCompactSnapshot, setHasCompactSnapshot] = useState(
|
||
() => !!loadThreadSnapshot(thread.id),
|
||
)
|
||
useEffect(() => {
|
||
const id = setInterval(() => {
|
||
setHasCompactSnapshot(!!loadThreadSnapshot(thread.id))
|
||
}, 1500)
|
||
return () => clearInterval(id)
|
||
}, [thread.id])
|
||
|
||
const composedSystemPrompt = useMemo(() => {
|
||
return buildSystemPrompt({
|
||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||
preface: buildAdminPreface(activeAgent, uiControl),
|
||
includeActions: uiControl,
|
||
})
|
||
}, [uiControl, activeAgent])
|
||
|
||
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 () => {
|
||
try {
|
||
await navigator.clipboard.writeText(buildTranscript())
|
||
setActionLog("Copied conversation to clipboard.")
|
||
} catch (e) {
|
||
setActionLog(
|
||
`Copy failed: ${e instanceof Error ? e.message : String(e)}`,
|
||
)
|
||
}
|
||
}, [buildTranscript])
|
||
|
||
const saveToLibrary = useCallback(() => {
|
||
if (messages.length === 0) {
|
||
setActionLog("Nothing to save.")
|
||
return
|
||
}
|
||
const md = buildTranscript()
|
||
const item = addLibraryItem({
|
||
kind: "conversation",
|
||
title: thread.title,
|
||
content: md,
|
||
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
|
||
agentName: activeAgent?.name,
|
||
agentRole: activeAgent?.role,
|
||
threadId: thread.id,
|
||
messageCount: messages.length,
|
||
})
|
||
setActionLog(`Saved to Library: "${item.title}".`)
|
||
}, [messages.length, buildTranscript, thread.title, thread.id, activeAgent])
|
||
|
||
const handoffToAgent = useCallback(
|
||
async (target: Agent) => {
|
||
if (!activeAgent || target.id === activeAgent.id) return
|
||
const recent = messages
|
||
.slice(-6)
|
||
.map(
|
||
(m) =>
|
||
`${m.role === "user" ? "User" : "Assistant"}: ${m.content
|
||
.trim()
|
||
.slice(0, 600)}`,
|
||
)
|
||
.join("\n\n")
|
||
const handoffSystem = `You are ${activeAgent.name} (${activeAgent.role}). Write ONE short paragraph (2–4 sentences) handing off this conversation to ${target.name} (${target.role}). Cover: what the user is trying to do, key decisions/constraints, what's still open. Address ${target.name} directly. No greetings, no commentary about the handoff process — just the briefing.`
|
||
try {
|
||
const briefing = await completeOneShot(
|
||
[
|
||
{ role: "system", content: handoffSystem },
|
||
{
|
||
role: "user",
|
||
content: `Recent turns:\n\n${recent}`,
|
||
},
|
||
],
|
||
{ maxTokens: 220 },
|
||
)
|
||
const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}`
|
||
// Stamp the existing thread messages (preserve their authorship) and
|
||
// attribute the handoff note itself to the OUTGOING agent.
|
||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||
const prior = thread.messages[i]
|
||
if (m.role === "user") return { role: "user", content: m.content }
|
||
return {
|
||
role: "assistant",
|
||
content: m.content,
|
||
agentId: prior?.agentId ?? activeAgent.id,
|
||
}
|
||
})
|
||
const next: ThreadMessage[] = [
|
||
...stamped,
|
||
{ role: "assistant", content: note, agentId: activeAgent.id },
|
||
]
|
||
updateThread(thread.id, { messages: next, agentId: target.id })
|
||
saveActiveAgentId(target.id)
|
||
setActionLog(`Handed off to ${target.name}.`)
|
||
onRemount()
|
||
} catch (e) {
|
||
setActionLog(
|
||
`Handoff failed: ${e instanceof Error ? e.message : String(e)}`,
|
||
)
|
||
}
|
||
},
|
||
[activeAgent, messages, completeOneShot, thread.id, onRemount],
|
||
)
|
||
|
||
const [compareOpen, setCompareOpen] = useState(false)
|
||
|
||
const exportMarkdown = useCallback(() => {
|
||
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 = `conversation-${stamp}.md`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
setActionLog("Exported conversation as Markdown.")
|
||
}, [buildTranscript])
|
||
|
||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||
const lastContent = messages.at(-1)?.content
|
||
useEffect(() => {
|
||
const el = scrollerRef.current
|
||
if (!el) return
|
||
el.scrollTop = el.scrollHeight
|
||
}, [messages.length, lastContent, isStreaming])
|
||
|
||
// Run action blocks when an assistant turn completes (and UI control is on).
|
||
const wasStreaming = useRef(false)
|
||
useEffect(() => {
|
||
if (wasStreaming.current && !isStreaming) {
|
||
const last = messages.at(-1)
|
||
if (uiControl && last?.role === "assistant" && last.content) {
|
||
void runActionBlocks(last.content).then((res) => {
|
||
if (res.ran > 0) {
|
||
setActionLog(`Ran ${res.ran} action block${res.ran > 1 ? "s" : ""}.`)
|
||
} else if (res.errors.length > 0) {
|
||
setActionLog(`Action error: ${res.errors[0]}`)
|
||
}
|
||
// Safety net: always return to the Assistant page once a UI Control
|
||
// sequence finishes, even if the model forgot the trailing nav step.
|
||
if (
|
||
res.ran > 0 &&
|
||
typeof window !== "undefined" &&
|
||
window.location.pathname !== "/assistant"
|
||
) {
|
||
setTimeout(() => {
|
||
const navAssistant = document.querySelector<HTMLElement>(
|
||
'[data-action="nav-assistant"]',
|
||
)
|
||
if (navAssistant) navAssistant.click()
|
||
}, 600)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
wasStreaming.current = isStreaming
|
||
}, [isStreaming, messages, uiControl])
|
||
|
||
const handleSend: (text: string) => void = (text: string) => {
|
||
const system = buildSystemPrompt({
|
||
path: window.location.pathname,
|
||
preface: buildAdminPreface(activeAgent, uiControl),
|
||
includeActions: uiControl,
|
||
})
|
||
void send(text, {
|
||
system,
|
||
tools: getOpenAITools(),
|
||
maxTokens: responseBudget,
|
||
})
|
||
}
|
||
handleSendRef.current = handleSend
|
||
|
||
// Consume a pending action queued by the parent across remounts (Retry).
|
||
useEffect(() => {
|
||
if (!pendingAction) return
|
||
if (pendingAction.kind === "retry") {
|
||
handleSend(pendingAction.text)
|
||
}
|
||
onPendingActionConsumed()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const usedTokens = useMemo(() => {
|
||
const system = buildSystemPrompt({
|
||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||
preface: buildAdminPreface(activeAgent, uiControl),
|
||
includeActions: uiControl,
|
||
})
|
||
const sysT = estimateTokens(system)
|
||
const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0)
|
||
return sysT + histT
|
||
}, [messages, uiControl, systemPrompt, activeAgent])
|
||
|
||
const suggestions = uiControl
|
||
? [
|
||
"Take me to the resources page",
|
||
"Type \"hello\" in the appbar search",
|
||
"Click the notifications button",
|
||
"Open the account menu",
|
||
"Show me what's on the screen",
|
||
]
|
||
: [
|
||
"What can this app do?",
|
||
"Draft a release note",
|
||
"Summarize this week's activity",
|
||
"Show me a SQL example",
|
||
]
|
||
|
||
return (
|
||
<div className="flex h-[calc(100svh-3.5rem-3rem)] flex-col gap-4">
|
||
{uiControl && showUiControlBanner && (
|
||
<div className="flex items-start gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground/80">
|
||
<span className="flex-1">
|
||
UI control is on. The assistant can navigate, click, and fill on
|
||
your behalf. Watch the cursor.
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowUiControlBanner(false)}
|
||
aria-label="Dismiss"
|
||
className="-my-1 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={scrollerRef}
|
||
className="flex-1 overflow-y-auto rounded-lg border bg-card/30 p-4"
|
||
>
|
||
{messages.length === 0 ? (
|
||
<EmptyState uiControl={uiControl} />
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{messages.map((m, i) => {
|
||
const isPinned = pinnedSet.has(i)
|
||
const isLastUser =
|
||
m.role === "user" &&
|
||
!messages.slice(i + 1).some((x) => x.role === "user")
|
||
const isEditing = editingIndex === i
|
||
const isUser = m.role === "user"
|
||
const msgAgentId =
|
||
!isUser
|
||
? thread.messages[i]?.agentId ?? activeAgent?.id
|
||
: undefined
|
||
const msgAgent = msgAgentId
|
||
? agents.find((a) => a.id === msgAgentId) ?? activeAgent
|
||
: undefined
|
||
return (
|
||
<div
|
||
key={i}
|
||
className={
|
||
"group flex w-full items-start gap-2 " +
|
||
(isUser ? "flex-row-reverse" : "flex-row")
|
||
}
|
||
>
|
||
{!isUser && msgAgent && (
|
||
<Avatar
|
||
className="mt-5 size-7 shrink-0 ring-1 ring-border"
|
||
title={`${msgAgent.name} — ${msgAgent.role}`}
|
||
>
|
||
<AvatarFallback
|
||
style={{
|
||
background: agentTint(msgAgent.id),
|
||
color: "var(--primary-foreground)",
|
||
}}
|
||
className="text-[11px] font-semibold"
|
||
>
|
||
{agentInitials(msgAgent.name)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
)}
|
||
<div
|
||
className={
|
||
"flex max-w-[80%] flex-col " +
|
||
(isUser ? "items-end" : "items-start")
|
||
}
|
||
>
|
||
<div className="mb-0.5 flex items-center gap-1.5 px-1">
|
||
<span className="text-xs font-medium text-muted-foreground">
|
||
{isUser
|
||
? "You"
|
||
: (msgAgent?.name ?? "Assistant")}
|
||
</span>
|
||
{!isUser && msgAgent && (
|
||
<span className="text-[10px] text-muted-foreground/70">
|
||
· {msgAgent.role}
|
||
</span>
|
||
)}
|
||
{isPinned && (
|
||
<Pin className="size-3 fill-primary text-primary" />
|
||
)}
|
||
</div>
|
||
{isEditing ? (
|
||
<div className="w-full rounded-2xl border border-primary/40 bg-background p-2">
|
||
<textarea
|
||
value={editingDraft}
|
||
onChange={(e) => setEditingDraft(e.target.value)}
|
||
autoFocus
|
||
rows={3}
|
||
className="w-full resize-none rounded-md bg-transparent px-2 py-1.5 text-sm leading-relaxed outline-none"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault()
|
||
submitEdit()
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault()
|
||
cancelEdit()
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex items-center justify-end gap-1 px-1">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={cancelEdit}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button size="sm" onClick={submitEdit}>
|
||
Save & retry
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={
|
||
m.role === "tool"
|
||
? "text-sm leading-relaxed"
|
||
: isUser
|
||
? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
|
||
: "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
|
||
}
|
||
>
|
||
{m.role === "tool" ? (
|
||
<MessageBody content={m.content} isToolResult />
|
||
) : m.content || (m.role === "assistant" && m.toolCalls?.length) ? (
|
||
isUser ? (
|
||
<span className="whitespace-pre-wrap">
|
||
{m.content}
|
||
</span>
|
||
) : (
|
||
<MessageBody content={m.content} toolCalls={m.toolCalls} />
|
||
)
|
||
) : isStreaming && i === messages.length - 1 ? (
|
||
"…"
|
||
) : null}
|
||
</div>
|
||
)}
|
||
{!isEditing && (
|
||
<div className="mt-0.5 flex items-center gap-0.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||
<button
|
||
type="button"
|
||
data-action={`assistant-msg-pin-${i}`}
|
||
onClick={() => togglePin(i)}
|
||
aria-label={isPinned ? "Unpin" : "Pin"}
|
||
title={
|
||
isPinned
|
||
? "Unpin (Compact will not preserve)"
|
||
: "Pin (Compact preserves verbatim)"
|
||
}
|
||
className={
|
||
"rounded p-1 hover:bg-accent " +
|
||
(isPinned
|
||
? "text-primary"
|
||
: "text-muted-foreground")
|
||
}
|
||
>
|
||
<Pin
|
||
className={
|
||
"size-3.5 " + (isPinned ? "fill-current" : "")
|
||
}
|
||
/>
|
||
</button>
|
||
{!isUser && (
|
||
<button
|
||
type="button"
|
||
data-action={`assistant-msg-speak-${i}`}
|
||
onClick={() => speak(i, m.content)}
|
||
disabled={!m.content}
|
||
aria-label={
|
||
speakingIndex === i ? "Stop reading" : "Read aloud"
|
||
}
|
||
title={
|
||
speakingIndex === i ? "Stop reading" : "Read aloud"
|
||
}
|
||
className="rounded p-1 text-muted-foreground hover:bg-accent disabled:opacity-30"
|
||
>
|
||
{speakingIndex === i ? (
|
||
<VolumeX className="size-3.5" />
|
||
) : (
|
||
<Volume2 className="size-3.5" />
|
||
)}
|
||
</button>
|
||
)}
|
||
{isUser && isLastUser && (
|
||
<button
|
||
type="button"
|
||
data-action={`assistant-msg-edit-${i}`}
|
||
onClick={() => beginEdit(i, m.content)}
|
||
disabled={isStreaming}
|
||
aria-label="Edit message"
|
||
title="Edit & retry"
|
||
className="rounded p-1 text-muted-foreground hover:bg-accent disabled:opacity-30"
|
||
>
|
||
<Pencil className="size-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{pendingConfirm?.afterIndex === i && (
|
||
<ConfirmCard
|
||
calls={pendingConfirm.writes}
|
||
onConfirm={onConfirmWrites}
|
||
onDeny={onDenyWrites}
|
||
busy={confirmBusy}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
{isStreaming && messages.at(-1)?.role !== "assistant" && (
|
||
<TypingIndicator />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{actionLog && (
|
||
<div className="rounded-lg border bg-card/40 px-3 py-2 text-xs text-muted-foreground">
|
||
{actionLog}
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||
{error.message}
|
||
</div>
|
||
)}
|
||
|
||
<div className="relative">
|
||
<CommandBar
|
||
chips={suggestions}
|
||
position="bottom"
|
||
placeholder={
|
||
uiControl ? "Ask me to do something…" : "Ask the assistant…"
|
||
}
|
||
onSubmit={handleSend}
|
||
onChipSelect={handleSend}
|
||
/>
|
||
<VoiceInputButton onTranscript={(t) => handleSend(t)} />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-card/50 px-3 py-2">
|
||
<StatusDot status={status} />
|
||
{status.kind === "probing" && (
|
||
<span className="text-xs text-muted-foreground">Probing…</span>
|
||
)}
|
||
{status.kind === "live" && (
|
||
<>
|
||
<KindChip kind={endpointKind(baseURL)} />
|
||
<HostChip baseURL={baseURL} />
|
||
</>
|
||
)}
|
||
{status.kind === "mock" && (
|
||
<span title={status.reason}>
|
||
<KindChip kind="mock" />
|
||
</span>
|
||
)}
|
||
<ContextGauge
|
||
used={usedTokens}
|
||
total={contextTokens}
|
||
warnAt={contextTokens - responseBudget}
|
||
/>
|
||
<ThreadsPicker
|
||
thread={thread}
|
||
threads={threads}
|
||
onSwitch={onSwitchThread}
|
||
onNew={onNewThread}
|
||
onDelete={onDeleteThread}
|
||
onRename={onRenameThread}
|
||
/>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
data-action="assistant-agent"
|
||
className="inline-flex items-center gap-2 rounded-md py-1 pl-1 pr-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
title={
|
||
activeAgent
|
||
? `${activeAgent.name} — ${activeAgent.role}`
|
||
: "Pick a persona"
|
||
}
|
||
>
|
||
<Avatar className="size-8 ring-2 ring-primary/30">
|
||
<AvatarFallback
|
||
style={{
|
||
background: agentTint(activeAgent?.id ?? ""),
|
||
color: "var(--primary-foreground)",
|
||
}}
|
||
className="text-xs font-semibold"
|
||
>
|
||
{agentInitials(activeAgent?.name)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="flex flex-col leading-tight">
|
||
<span className="text-sm font-medium">
|
||
{activeAgent?.name ?? "Agent"}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{activeAgent?.role}
|
||
</span>
|
||
</span>
|
||
<ChevronDown className="size-3.5 opacity-60" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
side="top"
|
||
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={() => setActiveAgentId(a.id)}
|
||
data-state={activeAgentId === a.id ? "checked" : undefined}
|
||
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>
|
||
))}
|
||
{agents.filter((a) => a.id !== activeAgentId).length > 0 &&
|
||
messages.length > 0 && (
|
||
<>
|
||
<div className="my-1 h-px bg-border" />
|
||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
Hand off (with briefing)
|
||
</div>
|
||
{agents
|
||
.filter((a) => a.id !== activeAgentId)
|
||
.map((a) => (
|
||
<DropdownMenuItem
|
||
key={`handoff-${a.id}`}
|
||
data-action={`assistant-handoff-${a.id}`}
|
||
onClick={() => void handoffToAgent(a)}
|
||
className="flex items-center gap-2.5"
|
||
>
|
||
<span className="inline-flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||
<ArrowRight className="size-3.5" />
|
||
</span>
|
||
<span className="flex min-w-0 flex-col">
|
||
<span className="truncate text-sm font-medium">
|
||
Hand off to {a.name}
|
||
</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{a.role}
|
||
</span>
|
||
</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{status.kind === "live" ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
data-action="assistant-model"
|
||
className="inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<span className="max-w-[18ch] truncate">
|
||
{model || status.models[0]}
|
||
</span>
|
||
<ChevronDown className="size-3.5 opacity-60" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
side="top"
|
||
sideOffset={6}
|
||
className="max-h-64 overflow-y-auto"
|
||
>
|
||
{status.models.map((m) => (
|
||
<DropdownMenuItem
|
||
key={m}
|
||
onClick={() => onModelChange(m)}
|
||
data-state={
|
||
(model || status.models[0]) === m ? "checked" : undefined
|
||
}
|
||
>
|
||
{m}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : (
|
||
<span className="rounded-md border bg-muted px-2 py-1 text-xs">
|
||
mock
|
||
</span>
|
||
)}
|
||
{uiControl && (
|
||
<span
|
||
className="hidden rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary md:inline"
|
||
title="UI Control is on"
|
||
>
|
||
UI · ON
|
||
</span>
|
||
)}
|
||
{isStreaming && (
|
||
<Button
|
||
data-action="assistant-stop"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={abort}
|
||
>
|
||
<Square className="size-3 fill-current" /> Stop
|
||
</Button>
|
||
)}
|
||
<Popover>
|
||
<PopoverTrigger
|
||
render={
|
||
<Button
|
||
data-action="assistant-actions"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="Conversation actions"
|
||
title="Actions"
|
||
>
|
||
<MoreHorizontal />
|
||
</Button>
|
||
}
|
||
/>
|
||
<PopoverContent align="end" side="top" className="w-80 p-2">
|
||
{/* UI Control toggle — full width */}
|
||
<button
|
||
type="button"
|
||
data-action="assistant-ui-control"
|
||
onClick={() => setUiControl((v) => !v)}
|
||
className="mb-2 flex w-full items-center gap-2 rounded-md border bg-card/40 px-2.5 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||
title={
|
||
uiControl
|
||
? "UI Control on — assistant can drive the UI"
|
||
: "UI Control off — chat-only"
|
||
}
|
||
>
|
||
<Wand2 className="size-4" />
|
||
<span className="flex flex-1 flex-col leading-tight">
|
||
<span className="font-medium">UI Control</span>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{uiControl ? "On" : "Off"}
|
||
</span>
|
||
</span>
|
||
<span
|
||
className={
|
||
"size-2 rounded-full " +
|
||
(uiControl ? "bg-primary" : "bg-muted-foreground/40")
|
||
}
|
||
aria-hidden
|
||
/>
|
||
</button>
|
||
|
||
<SectionLabel>Conversation</SectionLabel>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<ToolTile
|
||
data-action="assistant-regenerate"
|
||
onClick={regenerateLast}
|
||
disabled={
|
||
isStreaming ||
|
||
!messages.some((m) => m.role === "user")
|
||
}
|
||
icon={<RotateCcw className="size-4" />}
|
||
label="Regenerate"
|
||
title="Re-run the most recent prompt"
|
||
/>
|
||
<ToolTile
|
||
data-action="assistant-continue"
|
||
onClick={continueLast}
|
||
disabled={isStreaming || messages.length === 0}
|
||
icon={<ArrowRight className="size-4" />}
|
||
label="Continue"
|
||
title="Ask the model to keep going"
|
||
/>
|
||
<ToolTile
|
||
data-action="assistant-compact"
|
||
onClick={() => void compactConversation()}
|
||
disabled={compacting || isStreaming || messages.length < 2}
|
||
icon={
|
||
compacting ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : (
|
||
<Archive className="size-4" />
|
||
)
|
||
}
|
||
label="Compact"
|
||
title="Summarize older turns to free context"
|
||
/>
|
||
<ToolTile
|
||
data-action="assistant-restore-compact"
|
||
onClick={restoreCompact}
|
||
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="assistant-copy-md"
|
||
onClick={() => void copyMarkdown()}
|
||
disabled={messages.length === 0}
|
||
icon={<Copy className="size-4" />}
|
||
label="Copy MD"
|
||
title="Copy conversation as Markdown"
|
||
/>
|
||
<ToolTile
|
||
data-action="assistant-export-md"
|
||
onClick={exportMarkdown}
|
||
disabled={messages.length === 0}
|
||
icon={<Download className="size-4" />}
|
||
label="Export MD"
|
||
title="Download a .md file"
|
||
/>
|
||
<ToolTile
|
||
data-action="assistant-save-library"
|
||
onClick={saveToLibrary}
|
||
disabled={messages.length === 0}
|
||
icon={<BookmarkPlus className="size-4" />}
|
||
label="Save to Library"
|
||
title="Snapshot this conversation"
|
||
/>
|
||
<ToolTile
|
||
data-action="assistant-show-prompt"
|
||
onClick={() => setShowPromptOpen(true)}
|
||
icon={<FileText className="size-4" />}
|
||
label="Show prompt"
|
||
title="Preview the system prompt"
|
||
/>
|
||
</div>
|
||
|
||
<SectionLabel>Multi-agent</SectionLabel>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<ToolTile
|
||
data-action="assistant-compare"
|
||
onClick={() => setCompareOpen(true)}
|
||
disabled={
|
||
agents.length < 2 ||
|
||
!messages.some((m) => m.role === "user")
|
||
}
|
||
icon={<Users className="size-4" />}
|
||
label="Compare"
|
||
title="Run the last prompt through several personas"
|
||
/>
|
||
{status.kind === "mock" && (
|
||
<ToolTile
|
||
data-action="assistant-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="assistant-clear"
|
||
onClick={clearConversation}
|
||
disabled={messages.length === 0}
|
||
icon={<Trash2 className="size-4" />}
|
||
label="Clear conversation"
|
||
title="Wipe history and start fresh"
|
||
destructive
|
||
fullWidth
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
|
||
{showPromptOpen && (
|
||
<SystemPromptDialog
|
||
prompt={composedSystemPrompt}
|
||
uiControl={uiControl}
|
||
onClose={() => setShowPromptOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
{compareOpen && (
|
||
<CompareDialog
|
||
agents={agents}
|
||
basePrompt={basePrompt}
|
||
messages={messages as ThreadMessage[]}
|
||
responseBudget={responseBudget}
|
||
onClose={() => setCompareOpen(false)}
|
||
onAppend={(agentName, content) => {
|
||
const speaker = agents.find((a) => a.name === agentName)
|
||
const note = `🪞 **${agentName} says:**\n\n${content}`
|
||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||
const prior = thread.messages[i]
|
||
if (m.role === "user") return { role: "user", content: m.content }
|
||
return {
|
||
role: "assistant",
|
||
content: m.content,
|
||
agentId: prior?.agentId ?? activeAgent?.id,
|
||
}
|
||
})
|
||
const next: ThreadMessage[] = [
|
||
...stamped,
|
||
{
|
||
role: "assistant",
|
||
content: note,
|
||
agentId: speaker?.id ?? activeAgent?.id,
|
||
},
|
||
]
|
||
updateThread(thread.id, { messages: next })
|
||
setActionLog(`Appended ${agentName}'s reply to the thread.`)
|
||
setCompareOpen(false)
|
||
onRemount()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CompareDialog({
|
||
agents,
|
||
basePrompt,
|
||
messages,
|
||
responseBudget,
|
||
onClose,
|
||
onAppend,
|
||
}: {
|
||
agents: Agent[]
|
||
basePrompt: string
|
||
messages: ThreadMessage[]
|
||
responseBudget: number
|
||
onClose: () => void
|
||
onAppend: (agentName: string, content: string) => void
|
||
}) {
|
||
const lastUser = [...messages].reverse().find((m) => m.role === "user")
|
||
const [selected, setSelected] = useState<Set<string>>(
|
||
() => new Set(agents.map((a) => a.id)),
|
||
)
|
||
const [prompt, setPrompt] = useState(lastUser?.content ?? "")
|
||
const [results, setResults] = useState<
|
||
Record<string, { state: "pending" | "ok" | "fail"; content: string }>
|
||
>({})
|
||
const [running, setRunning] = useState(false)
|
||
const { complete } = useCompletion()
|
||
|
||
const toggle = (id: string) => {
|
||
const next = new Set(selected)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
setSelected(next)
|
||
}
|
||
|
||
const run = async () => {
|
||
if (!prompt.trim() || selected.size === 0) return
|
||
setRunning(true)
|
||
const initial: typeof results = {}
|
||
for (const id of selected) {
|
||
initial[id] = { state: "pending", content: "" }
|
||
}
|
||
setResults(initial)
|
||
await Promise.all(
|
||
Array.from(selected).map(async (id) => {
|
||
const a = agents.find((x) => x.id === id)
|
||
if (!a) return
|
||
const sys = `${basePrompt}\n\nActive persona: ${a.name} — ${a.role}\n${a.prompt}`
|
||
try {
|
||
const out = await complete(
|
||
[
|
||
{ role: "system", content: sys },
|
||
{ role: "user", content: prompt },
|
||
],
|
||
{ maxTokens: responseBudget },
|
||
)
|
||
setResults((cur) => ({
|
||
...cur,
|
||
[id]: { state: "ok", content: out },
|
||
}))
|
||
} catch (e) {
|
||
setResults((cur) => ({
|
||
...cur,
|
||
[id]: {
|
||
state: "fail",
|
||
content: e instanceof Error ? e.message : String(e),
|
||
},
|
||
}))
|
||
}
|
||
}),
|
||
)
|
||
setRunning(false)
|
||
}
|
||
|
||
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-[88vh] w-full max-w-5xl flex-col rounded-lg border bg-card shadow-lg"
|
||
>
|
||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||
<Users className="size-4 text-muted-foreground" />
|
||
<div className="flex flex-1 flex-col">
|
||
<span className="text-sm font-semibold">Compare across agents</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
Run the same prompt through several personas in parallel.
|
||
</span>
|
||
</div>
|
||
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close">
|
||
<Square className="size-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3 border-b px-4 py-3">
|
||
<textarea
|
||
value={prompt}
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
rows={3}
|
||
className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring/40"
|
||
placeholder="Prompt to send to each selected agent…"
|
||
/>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{agents.map((a) => {
|
||
const on = selected.has(a.id)
|
||
return (
|
||
<button
|
||
key={a.id}
|
||
type="button"
|
||
onClick={() => toggle(a.id)}
|
||
className={
|
||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs " +
|
||
(on
|
||
? "border-primary/40 bg-primary/10 text-primary"
|
||
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground")
|
||
}
|
||
>
|
||
<span
|
||
className="inline-block size-2 rounded-full"
|
||
style={{ background: agentTint(a.id) }}
|
||
aria-hidden
|
||
/>
|
||
<span>{a.name}</span>
|
||
<span className="opacity-60">· {a.role}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground">
|
||
{selected.size} selected
|
||
</span>
|
||
<Button
|
||
onClick={() => void run()}
|
||
disabled={running || selected.size === 0 || !prompt.trim()}
|
||
size="sm"
|
||
>
|
||
{running ? (
|
||
<Loader2 className="size-3.5 animate-spin" />
|
||
) : (
|
||
<ArrowRight className="size-3.5" />
|
||
)}
|
||
Run
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid flex-1 grid-cols-1 gap-3 overflow-auto p-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{Array.from(selected).map((id) => {
|
||
const a = agents.find((x) => x.id === id)
|
||
if (!a) return null
|
||
const r = results[id]
|
||
return (
|
||
<div
|
||
key={id}
|
||
className="flex flex-col rounded-lg border bg-background"
|
||
>
|
||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||
<span
|
||
className="size-2 rounded-full"
|
||
style={{ background: agentTint(a.id) }}
|
||
aria-hidden
|
||
/>
|
||
<div className="flex flex-1 flex-col leading-tight">
|
||
<span className="text-sm font-medium">{a.name}</span>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{a.role}
|
||
</span>
|
||
</div>
|
||
{r?.state === "ok" && (
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => onAppend(a.name, r.content)}
|
||
title="Append to thread"
|
||
>
|
||
<ArrowRight className="size-3.5" /> Use
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="max-h-72 min-h-32 overflow-auto p-3 text-xs leading-relaxed">
|
||
{!r ? (
|
||
<span className="text-muted-foreground">
|
||
Click Run to generate.
|
||
</span>
|
||
) : r.state === "pending" ? (
|
||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||
<Loader2 className="size-3 animate-spin" /> Thinking…
|
||
</span>
|
||
) : r.state === "fail" ? (
|
||
<span className="text-destructive">Error: {r.content}</span>
|
||
) : (
|
||
<pre className="whitespace-pre-wrap font-sans">
|
||
{r.content}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SystemPromptDialog({
|
||
prompt,
|
||
uiControl,
|
||
onClose,
|
||
}: {
|
||
prompt: string
|
||
uiControl: boolean
|
||
onClose: () => void
|
||
}) {
|
||
const copy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(prompt)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
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">
|
||
{uiControl
|
||
? "UI Control on — includes action DSL + persona + live actions"
|
||
: "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">
|
||
<Square className="size-3" />
|
||
</Button>
|
||
</div>
|
||
<pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
|
||
{prompt}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ContextGauge({
|
||
used,
|
||
total,
|
||
warnAt,
|
||
}: {
|
||
used: number
|
||
total: number
|
||
warnAt: number
|
||
}) {
|
||
const pct = total > 0 ? Math.min(1, used / total) : 0
|
||
const warn = used > warnAt
|
||
const fmt = (n: number) =>
|
||
n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${n}`
|
||
return (
|
||
<span
|
||
className="inline-flex min-w-0 items-center gap-2 text-xs tabular-nums text-muted-foreground"
|
||
title={`${used} / ${total} tokens used (${Math.round(pct * 100)}%). Response capped at ${total - warnAt}.`}
|
||
>
|
||
<span
|
||
role="progressbar"
|
||
aria-valuemin={0}
|
||
aria-valuemax={total}
|
||
aria-valuenow={used}
|
||
aria-label="Context tokens used"
|
||
className="relative h-1.5 w-32 overflow-hidden rounded-full bg-muted"
|
||
>
|
||
<span
|
||
className={`absolute inset-y-0 left-0 rounded-full transition-[width] duration-200 ease-out ${
|
||
warn ? "bg-amber-500" : "bg-primary"
|
||
}`}
|
||
style={{ width: `${pct * 100}%` }}
|
||
/>
|
||
</span>
|
||
<span className={warn ? "text-amber-600 dark:text-amber-300" : ""}>
|
||
{fmt(used)}/{fmt(total)}
|
||
</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
type SpeechRecognitionLike = {
|
||
start: () => void
|
||
stop: () => void
|
||
abort: () => void
|
||
onresult: ((e: { results: { 0: { transcript: string } }[] }) => void) | null
|
||
onerror: ((e: unknown) => void) | null
|
||
onend: (() => void) | null
|
||
lang: string
|
||
interimResults: boolean
|
||
continuous: boolean
|
||
}
|
||
|
||
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="assistant-voice"
|
||
onClick={() => (listening ? stop() : start())}
|
||
aria-label={listening ? "Stop listening" : "Voice input"}
|
||
title={listening ? "Stop listening" : "Voice input"}
|
||
className={
|
||
"absolute right-4 top-1/2 z-30 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-full border bg-background shadow-sm transition-colors " +
|
||
(listening
|
||
? "border-destructive/50 bg-destructive/10 text-destructive animate-pulse"
|
||
: "text-muted-foreground hover:bg-accent hover:text-foreground")
|
||
}
|
||
>
|
||
{listening ? <MicOff className="size-4" /> : <Mic className="size-4" />}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function ThreadsPicker({
|
||
thread,
|
||
threads,
|
||
onSwitch,
|
||
onNew,
|
||
onDelete,
|
||
onRename,
|
||
}: {
|
||
thread: Thread
|
||
threads: Thread[]
|
||
onSwitch: (id: string) => void
|
||
onNew: () => void
|
||
onDelete: (id: string) => void
|
||
onRename: (id: string, title: string) => void
|
||
}) {
|
||
const sorted = [...threads].sort((a, b) => b.updatedAt - a.updatedAt)
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
data-action="assistant-thread"
|
||
className="inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
title="Conversations"
|
||
>
|
||
<MessagesSquare className="size-3.5 opacity-70" />
|
||
<span className="max-w-[14ch] truncate">{thread.title}</span>
|
||
<ChevronDown className="size-3.5 opacity-60" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
side="top"
|
||
sideOffset={6}
|
||
className="max-h-80 w-72 overflow-y-auto p-1"
|
||
>
|
||
<button
|
||
type="button"
|
||
data-action="assistant-thread-new"
|
||
onClick={onNew}
|
||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||
>
|
||
<Plus className="size-4" />
|
||
<span className="font-medium">New conversation</span>
|
||
</button>
|
||
<div className="my-1 h-px bg-border" />
|
||
{sorted.map((t) => {
|
||
const isActive = t.id === thread.id
|
||
return (
|
||
<div
|
||
key={t.id}
|
||
className={
|
||
"group flex items-center gap-1 rounded-md " +
|
||
(isActive ? "bg-accent" : "")
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
data-action={`assistant-thread-switch-${t.id}`}
|
||
onClick={() => onSwitch(t.id)}
|
||
className="flex flex-1 flex-col items-start gap-0.5 px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||
>
|
||
<span className="line-clamp-1 font-medium">{t.title}</span>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{t.messages.length} msg ·{" "}
|
||
{new Date(t.updatedAt).toLocaleDateString()}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-action={`assistant-thread-rename-${t.id}`}
|
||
onClick={() => {
|
||
const next = window.prompt("Rename conversation", t.title)
|
||
if (next && next.trim()) onRename(t.id, next.trim())
|
||
}}
|
||
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground group-hover:opacity-100"
|
||
title="Rename"
|
||
aria-label="Rename"
|
||
>
|
||
<Pencil className="size-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-action={`assistant-thread-delete-${t.id}`}
|
||
onClick={() => {
|
||
if (threads.length <= 1) return
|
||
if (window.confirm(`Delete "${t.title}"?`)) onDelete(t.id)
|
||
}}
|
||
disabled={threads.length <= 1}
|
||
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
|
||
title="Delete"
|
||
aria-label="Delete"
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</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 {
|
||
// Deterministic OKLCH hue from the id so each persona gets a stable colour.
|
||
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 StatusDot({ status }: { status: Status }) {
|
||
const cls =
|
||
status.kind === "probing"
|
||
? "bg-muted-foreground/40"
|
||
: status.kind === "live"
|
||
? "bg-emerald-500 ring-2 ring-emerald-500/25 shadow-[0_0_6px_var(--color-emerald-500)] animate-pulse"
|
||
: "bg-rose-500 ring-2 ring-rose-500/25"
|
||
const label =
|
||
status.kind === "live"
|
||
? "Connected"
|
||
: status.kind === "mock"
|
||
? "Disconnected — using mock"
|
||
: "Probing"
|
||
return (
|
||
<span
|
||
role="status"
|
||
aria-label={label}
|
||
title={label}
|
||
className={`size-2.5 shrink-0 rounded-full ${cls}`}
|
||
data-status={status.kind}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function endpointKind(url: string): "local" | "api" {
|
||
try {
|
||
const u = new URL(url)
|
||
if (
|
||
u.hostname === "localhost" ||
|
||
u.hostname === "127.0.0.1" ||
|
||
u.hostname === "0.0.0.0" ||
|
||
u.hostname.endsWith(".local")
|
||
) {
|
||
return "local"
|
||
}
|
||
return "api"
|
||
} catch {
|
||
return "api"
|
||
}
|
||
}
|
||
|
||
function KindChip({ kind }: { kind: "local" | "api" | "mock" }) {
|
||
const styles: Record<typeof kind, string> = {
|
||
local:
|
||
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||
api: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||
mock: "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||
}
|
||
const label = kind === "local" ? "Local" : kind === "api" ? "API" : "Mock"
|
||
return (
|
||
<span
|
||
className={`rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${styles[kind]}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function HostChip({ baseURL }: { baseURL: string }) {
|
||
let host = baseURL.replace(/^https?:\/\//, "")
|
||
try {
|
||
const u = new URL(baseURL)
|
||
host = `${u.hostname}${u.port ? `:${u.port}` : ""}`
|
||
} catch {
|
||
/* fall back to stripped string */
|
||
}
|
||
return (
|
||
<span
|
||
className="rounded-full border bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||
title={baseURL}
|
||
>
|
||
{host}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function EmptyState({ uiControl }: { uiControl: boolean }) {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||
<p
|
||
style={{ fontFamily: "var(--font-ai-prose)" }}
|
||
className="text-lg text-foreground"
|
||
>
|
||
{uiControl ? "Tell me what you'd like to do." : "How can I help?"}
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{uiControl
|
||
? "I can navigate, click buttons, and fill forms. Watch the cursor."
|
||
: "Pick a suggestion below or type a question."}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|