compassmock/src/components/agent/chat-provider.tsx
Nicholai dc0cd40b13
feat(agent): add Claude Code bridge integration (#60)
Add local daemon that routes inference through user's own Anthropic
API key with filesystem and terminal access. Includes WebSocket
transport, MCP tool adapter, and API key auth.

Key components:
- compass-bridge package: local daemon with tool registry
- WebSocket transport for agent communication
- MCP API key management with HMAC auth and scoped permissions
- Usage tracking (tool calls, duration, success/failure)
- Settings UI for Claude Code configuration
- Migration 0019: mcp_api_keys and mcp_usage tables
- Test suite for auth and transport layers

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-09 00:29:00 -07:00

617 lines
15 KiB
TypeScript
Executable File

"use client"
import * as React from "react"
import { type UIMessage } from "ai"
import { useUIStream, type Spec } from "@json-render/react"
import { usePathname, useRouter } from "next/navigation"
import {
saveConversation,
loadConversation,
loadConversations,
} from "@/app/actions/agent"
import { getTextFromParts } from "@/lib/agent/chat-adapter"
import { useCompassChat } from "@/hooks/use-compass-chat"
import {
WebSocketChatTransport,
detectBridge,
} from "@/lib/agent/ws-transport"
import {
getBridgeSnapshot,
subscribeBridge,
setBridgeConnected as storeBridgeConnected,
setBridgeEnabled as storeBridgeEnabled,
} from "@/lib/agent/bridge-store"
// --- Panel context (open/close sidebar) ---
interface PanelContextValue {
readonly isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
const PanelContext =
React.createContext<PanelContextValue | null>(null)
export function useChatPanel(): PanelContextValue {
const ctx = React.useContext(PanelContext)
if (!ctx) {
throw new Error(
"useChatPanel must be used within a ChatProvider"
)
}
return ctx
}
// --- Chat state context ---
interface ChatStateValue {
readonly messages: ReadonlyArray<UIMessage>
setMessages: (
messages:
| UIMessage[]
| ((prev: UIMessage[]) => UIMessage[])
) => void
sendMessage: (params: { text: string }) => void
regenerate: () => void
stop: () => void
readonly status: string
readonly isGenerating: boolean
readonly conversationId: string
newChat: () => void
readonly pathname: string
}
const ChatStateContext =
React.createContext<ChatStateValue | null>(null)
export function useChatState(): ChatStateValue {
const ctx = React.useContext(ChatStateContext)
if (!ctx) {
throw new Error(
"useChatState must be used within a ChatProvider"
)
}
return ctx
}
// --- Render state context ---
interface RenderContextValue {
readonly spec: Spec | null
readonly isRendering: boolean
readonly error: Error | null
readonly dataContext: Record<string, unknown>
triggerRender: (
prompt: string,
data: Record<string, unknown>
) => void
clearRender: () => void
loadSpec: (
spec: Spec,
data: Record<string, unknown>
) => void
}
const RenderContext =
React.createContext<RenderContextValue | null>(null)
export function useRenderState(): RenderContextValue {
const ctx = React.useContext(RenderContext)
if (!ctx) {
throw new Error(
"useRenderState must be used within a ChatProvider"
)
}
return ctx
}
// --- Bridge state (module-level store, works anywhere) ---
export interface BridgeContextValue {
readonly bridgeConnected: boolean
readonly bridgeEnabled: boolean
setBridgeEnabled: (enabled: boolean) => void
}
export function useBridgeState(): BridgeContextValue {
const snapshot = React.useSyncExternalStore(
subscribeBridge,
getBridgeSnapshot,
getBridgeSnapshot
)
return React.useMemo(
() => ({
bridgeConnected: snapshot.connected,
bridgeEnabled: snapshot.enabled,
setBridgeEnabled: storeBridgeEnabled,
}),
[snapshot]
)
}
// --- Backward compat aliases ---
export function useAgent(): PanelContextValue {
return useChatPanel()
}
export function useAgentOptional(): PanelContextValue | null {
return React.useContext(PanelContext)
}
// --- Helper: extract generateUI output from parts ---
function findGenerateUIOutput(
parts: ReadonlyArray<unknown>,
dispatched: Set<string>
): {
renderPrompt: string
dataContext: Record<string, unknown>
callId: string
} | null {
for (const part of parts) {
const p = part as Record<string, unknown>
const pType = p.type as string | undefined
// handle both static tool parts (tool-<name>)
// and dynamic tool parts (dynamic-tool)
const isToolPart =
typeof pType === "string" &&
(pType.startsWith("tool-") ||
pType === "dynamic-tool")
if (!isToolPart) continue
const state = p.state as string | undefined
if (state !== "output-available") continue
const callId = p.toolCallId as string | undefined
if (!callId || dispatched.has(callId)) continue
const output = p.output as
| Record<string, unknown>
| undefined
if (output?.action !== "generateUI") continue
return {
renderPrompt: output.renderPrompt as string,
dataContext:
(output.dataContext as Record<
string,
unknown
>) ?? {},
callId,
}
}
return null
}
// --- Provider component ---
export function ChatProvider({
children,
}: {
readonly children: React.ReactNode
}) {
const [isOpen, setIsOpen] = React.useState(false)
const [conversationId, setConversationId] =
React.useState("")
// generate initial ID client-side only to avoid hydration mismatch
React.useEffect(() => {
setConversationId((prev) =>
prev === "" ? crypto.randomUUID() : prev
)
}, [])
const [resumeLoaded, setResumeLoaded] =
React.useState(false)
const [dataContext, setDataContext] = React.useState<
Record<string, unknown>
>({})
const [loadedSpec, setLoadedSpec] =
React.useState<Spec | null>(null)
const router = useRouter()
const pathname = usePathname()
// --- Bridge daemon state (reads from module store) ---
const bridge = useBridgeState()
// detect bridge on interval, write to store
React.useEffect(() => {
let cancelled = false
const check = async () => {
const connected = await detectBridge()
if (!cancelled) storeBridgeConnected(connected)
}
check()
const interval = setInterval(check, 15000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [])
const bridgeTransport = React.useMemo(() => {
if (bridge.bridgeConnected && bridge.bridgeEnabled) {
return new WebSocketChatTransport()
}
return null
}, [bridge.bridgeConnected, bridge.bridgeEnabled])
const chat = useCompassChat({
conversationId,
openPanel: () => setIsOpen(true),
bridgeTransport,
onFinish: async ({ messages: finalMessages }) => {
if (finalMessages.length === 0) return
const serialized = finalMessages.map((m) => ({
id: m.id,
role: m.role,
content: getTextFromParts(
m.parts as ReadonlyArray<{
type: string
text?: string
}>
),
parts: m.parts,
createdAt: new Date().toISOString(),
}))
await saveConversation(conversationId, serialized)
},
})
// UI stream for json-render — stabilize callbacks
const onRenderError = React.useCallback(
(err: Error) => {
console.error("Render stream error:", err)
},
[]
)
const renderStream = useUIStream({
api: "/api/agent/render",
onError: onRenderError,
})
// use refs to avoid stale closures and
// unstable effect deps
const renderSendRef = React.useRef(renderStream.send)
renderSendRef.current = renderStream.send
const renderSpecRef = React.useRef(renderStream.spec)
renderSpecRef.current = renderStream.spec
const renderClearRef = React.useRef(renderStream.clear)
renderClearRef.current = renderStream.clear
const pathnameRef = React.useRef(pathname)
pathnameRef.current = pathname
const routerRef = React.useRef(router)
routerRef.current = router
const loadedSpecRef = React.useRef(loadedSpec)
loadedSpecRef.current = loadedSpec
const triggerRender = React.useCallback(
(prompt: string, data: Record<string, unknown>) => {
setDataContext(data)
setLoadedSpec(null)
renderSendRef.current(prompt, {
dataContext: data,
previousSpec:
renderSpecRef.current ??
loadedSpecRef.current ??
undefined,
})
},
[]
)
const clearRender = React.useCallback(() => {
renderClearRef.current()
setDataContext({})
setLoadedSpec(null)
}, [])
const loadSpec = React.useCallback(
(spec: Spec, data: Record<string, unknown>) => {
renderClearRef.current()
setLoadedSpec(spec)
setDataContext(data)
},
[],
)
// watch chat messages for generateUI tool results
// and trigger render stream directly (no event chain)
const renderDispatchedRef = React.useRef(
new Set<string>()
)
React.useEffect(() => {
const lastMsg = chat.messages.at(-1)
if (!lastMsg || lastMsg.role !== "assistant") return
const result = findGenerateUIOutput(
lastMsg.parts as ReadonlyArray<unknown>,
renderDispatchedRef.current
)
if (!result) return
renderDispatchedRef.current.add(result.callId)
// navigate to /dashboard if not there
if (pathnameRef.current !== "/dashboard") {
routerRef.current.push("/dashboard")
}
// open chat panel for sidebar mode
setIsOpen(true)
// trigger the render stream
triggerRender(result.renderPrompt, result.dataContext)
}, [chat.messages, triggerRender])
// listen for save-dashboard events from tool dispatch
React.useEffect(() => {
const handler = async (e: Event) => {
const detail = (e as CustomEvent).detail as {
name?: string
description?: string
dashboardId?: string
}
if (!detail?.name) return
const currentSpec =
renderSpecRef.current ?? loadedSpecRef.current
if (!currentSpec) return
const { saveCustomDashboard } = await import(
"@/app/actions/dashboards"
)
const result = await saveCustomDashboard(
detail.name,
detail.description ?? "",
JSON.stringify(currentSpec),
JSON.stringify([]),
detail.name,
detail.dashboardId,
)
if (result.success) {
window.dispatchEvent(
new CustomEvent("agent-toast", {
detail: {
message: `Dashboard "${detail.name}" saved`,
type: "success",
},
})
)
} else {
window.dispatchEvent(
new CustomEvent("agent-toast", {
detail: {
message: result.error,
type: "error",
},
})
)
}
}
window.addEventListener(
"agent-save-dashboard",
handler
)
return () =>
window.removeEventListener(
"agent-save-dashboard",
handler
)
}, [])
// listen for load-dashboard events from tool dispatch
React.useEffect(() => {
const handler = async (e: Event) => {
const detail = (e as CustomEvent).detail as {
dashboardId?: string
spec?: Spec
queries?: string
renderPrompt?: string
editPrompt?: string
}
if (!detail?.spec) return
// run saved queries for fresh data
let freshData: Record<string, unknown> = {}
if (detail.queries) {
const { executeDashboardQueries } = await import(
"@/app/actions/dashboards"
)
const result = await executeDashboardQueries(
detail.queries,
)
if (result.success) {
freshData = result.data
}
}
loadSpec(detail.spec, freshData)
// navigate to /dashboard
if (pathnameRef.current !== "/dashboard") {
routerRef.current.push("/dashboard")
}
setIsOpen(true)
// if editPrompt provided, trigger re-render
if (detail.editPrompt) {
setTimeout(() => {
triggerRender(detail.editPrompt!, freshData)
}, 100)
}
}
window.addEventListener(
"agent-load-dashboard",
handler
)
return () =>
window.removeEventListener(
"agent-load-dashboard",
handler
)
}, [loadSpec, triggerRender])
// listen for navigation events from rendered UI
React.useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as {
path?: string
}
if (detail?.path) {
routerRef.current.push(detail.path)
}
}
window.addEventListener(
"agent-render-navigate",
handler
)
return () =>
window.removeEventListener(
"agent-render-navigate",
handler
)
}, [])
// resume last conversation on first open
React.useEffect(() => {
if (!isOpen || resumeLoaded) return
const resume = async () => {
const result = await loadConversations()
if (
!result.success ||
!result.data ||
result.data.length === 0
) {
setResumeLoaded(true)
return
}
const lastConv = result.data[0]
const msgResult = await loadConversation(lastConv.id)
if (
!msgResult.success ||
!msgResult.data ||
msgResult.data.length === 0
) {
setResumeLoaded(true)
return
}
setConversationId(lastConv.id)
const restored: UIMessage[] = msgResult.data.map(
(m) => ({
id: m.id,
role: m.role as "user" | "assistant",
parts:
(m.parts as UIMessage["parts"]) ?? [
{ type: "text" as const, text: m.content },
],
})
)
chat.setMessages(restored)
setResumeLoaded(true)
}
resume()
}, [isOpen, resumeLoaded, chat.setMessages])
const newChat = React.useCallback(() => {
chat.setMessages([])
setConversationId(crypto.randomUUID())
setResumeLoaded(true)
clearRender()
setLoadedSpec(null)
renderDispatchedRef.current.clear()
}, [chat.setMessages, clearRender])
const panelValue = React.useMemo(
() => ({
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev),
}),
[isOpen]
)
const chatValue = React.useMemo(
() => ({
messages: chat.messages,
setMessages: chat.setMessages,
sendMessage: chat.sendMessage,
regenerate: chat.regenerate,
stop: chat.stop,
status: chat.status,
isGenerating: chat.isGenerating,
conversationId,
newChat,
pathname: chat.pathname,
}),
[
chat.messages,
chat.setMessages,
chat.sendMessage,
chat.regenerate,
chat.stop,
chat.status,
chat.isGenerating,
conversationId,
newChat,
chat.pathname,
]
)
const renderValue = React.useMemo(
() => ({
spec: renderStream.spec ?? loadedSpec,
isRendering: renderStream.isStreaming,
error: renderStream.error,
dataContext,
triggerRender,
clearRender,
loadSpec,
}),
[
renderStream.spec,
loadedSpec,
renderStream.isStreaming,
renderStream.error,
dataContext,
triggerRender,
clearRender,
loadSpec,
]
)
return (
<PanelContext.Provider value={panelValue}>
<ChatStateContext.Provider value={chatValue}>
<RenderContext.Provider value={renderValue}>
{children}
</RenderContext.Provider>
</ChatStateContext.Provider>
</PanelContext.Provider>
)
}