Agent Chat Channel
Real-time conversational interface between engineers and domain agents, embedded in the Dashboard and IDE extensions.
Table of contents
- Overview
- New TypeScript Types
- WebSocket Event Types
- REST Endpoints
- Hooks
- Zustand Store
- Dashboard Integration
- IDE Integration
- Backend Architecture
- Component Specifications
- Context Assembly
- Message Lifecycle
- Component Hierarchy
- WebSocket Streaming Flow
- Relationship to Existing Features
- Related Documents
Overview
The Agent Chat Channel enables engineers to have real-time, contextual conversations with domain agents (ME, EE, FW, SC, SIM) while working in their tools. Instead of the current one-directional interaction model (agents propose, humans approve/reject), chat turns this into a dialogue — engineers can ask questions, request re-runs, and get explanations before making decisions.
Two rendering surfaces share one backend:
- Dashboard — persistent sidebar and contextual panels on Approvals, BOM, Digital Twin, and Sessions pages
- IDE extensions — VS Code sidebar panel, KiCad plugin panel (same WebSocket protocol, same message types)
This feature is scoped to design-context conversations — questions about specific approvals, components, simulation results, and session traces. It is NOT a general messaging or team chat system.
Feature Matrix
| Entry Point | Surface | Scope | Default Agent |
|---|---|---|---|
| Dashboard sidebar | Dashboard | project |
Orchestrator-chosen |
| Approvals panel | Dashboard | approval |
Agent that proposed the change |
| BOM panel | Dashboard | bom-entry |
SC (Supply Chain) |
| Digital Twin panel | Dashboard | digital-twin-node |
ME (Mechanical) |
| Sessions panel | Dashboard | session |
Orchestrator-chosen |
| VS Code sidebar | IDE | project or file-scoped |
FW (Firmware) |
| KiCad plugin panel | IDE | bom-entry or schematic-scoped |
EE (Electronics) |
New TypeScript Types
src/types/chat.ts
All types follow existing conventions: interface for objects, type for unions, ISO string timestamps, UUID string IDs.
// ---------------------------------------------------------------------------
// Actor types
// ---------------------------------------------------------------------------
/** Who can participate in a chat conversation */
export type ChatActorKind = 'user' | 'agent' | 'system';
/** A participant in a chat conversation */
export interface ChatActor {
/** Unique identifier (user ID or agent code) */
id: string;
/** Actor classification */
kind: ChatActorKind;
/** Display name (e.g. "Jane Doe" or "Mechanical Agent") */
displayName: string;
/** Agent code when kind is 'agent' (e.g. 'ME', 'EE', 'FW') */
agentCode?: string;
}
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
/** Delivery status of a chat message */
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'error';
/** A single message in a chat thread */
export interface ChatMessage {
/** Unique message identifier */
id: string;
/** Thread this message belongs to */
threadId: string;
/** Who sent this message */
actor: ChatActor;
/** Message content (Markdown supported) */
content: string;
/** Current delivery status */
status: MessageStatus;
/** ISO timestamp when the message was created */
createdAt: string;
/** ISO timestamp when the message was last updated (e.g. streaming complete) */
updatedAt?: string;
/** Optional reference to a graph entity this message relates to */
graphRef?: ChatGraphRef;
}
/** Reference to a graph entity for contextual messages */
export interface ChatGraphRef {
/** Node ID in the design graph */
nodeId: string;
/** Node type (e.g. 'requirement', 'component', 'test') */
nodeType: string;
/** Human-readable label */
label: string;
}
// ---------------------------------------------------------------------------
// Scope types
// ---------------------------------------------------------------------------
/** Defines the context for a chat conversation */
export interface ChatScope {
/** What kind of entity this conversation is about */
kind: 'session' | 'approval' | 'bom-entry' | 'digital-twin-node' | 'project';
/** ID of the scoped entity (session ID, approval ID, BOM entry ID, node ID, or project ID) */
entityId: string;
/** Optional human-readable label for the scoped entity */
label?: string;
}
// ---------------------------------------------------------------------------
// Thread and channel types
// ---------------------------------------------------------------------------
/** A conversation thread between an engineer and one or more agents */
export interface ChatThread {
/** Unique thread identifier */
id: string;
/** Conversation scope — what this thread is about */
scope: ChatScope;
/** Channel this thread belongs to */
channelId: string;
/** Thread title (auto-generated or user-defined) */
title: string;
/** All messages in this thread (may be paginated) */
messages: ChatMessage[];
/** Participants in this thread */
participants: ChatActor[];
/** ISO timestamp when the thread was created */
createdAt: string;
/** ISO timestamp of the most recent message */
lastMessageAt: string;
/** Whether the thread is archived */
archived: boolean;
}
/** A chat channel groups threads by scope kind */
export interface ChatChannel {
/** Unique channel identifier */
id: string;
/** Channel display name (e.g. "Approvals", "BOM", "General") */
name: string;
/** Scope kind this channel serves */
scopeKind: ChatScope['kind'];
/** Number of unread messages across all threads in this channel */
unreadCount: number;
}
WebSocket Event Payloads
/** Payload for chat.message.created events */
export interface ChatMessageCreatedEvent {
/** The complete message that was created */
message: ChatMessage;
/** Thread ID for targeted UI updates */
threadId: string;
}
/** Payload for chat.message.chunk events (streaming LLM responses) */
export interface ChatMessageChunkEvent {
/** Message ID being streamed */
messageId: string;
/** Thread ID for targeted UI updates */
threadId: string;
/** The text chunk to append */
chunk: string;
/** Whether this is the final chunk */
done: boolean;
}
/** Payload for chat.agent.typing events */
export interface ChatAgentTypingEvent {
/** Thread ID where the agent is typing */
threadId: string;
/** The agent that is typing */
agent: ChatActor;
/** Whether the agent started or stopped typing */
isTyping: boolean;
}
WebSocket Event Types
Four new values added to the WebSocketEventType union in src/types/websocket.ts:
export type WebSocketEventType =
// ... existing event types ...
| 'chat.message.created'
| 'chat.message.chunk'
| 'chat.agent.typing'
| 'chat.thread.created';
EVENT_MAP Additions
New entries in the EVENT_MAP in src/hooks/use-event-stream.ts:
const EVENT_MAP: Record<string, InvalidationRule> = {
// ... existing entries ...
"chat.message.created": {
keys: [["chat-threads"]],
},
"chat.thread.created": {
keys: [["chat-threads"], ["chat-channels"]],
},
};
chat.message.chunkandchat.agent.typingevents are handled imperatively in thechat-store(viaappendStreamChunkandsetAgentTyping), not through TanStack Query cache invalidation. Streaming content updates at high frequency — invalidating the query cache on every chunk would cause unnecessary re-renders.
REST Endpoints
src/api/endpoints/chat.ts
Follows the existing Axios client pattern established in src/api/client.ts.
import { client } from "@/api/client";
import type {
ChatThread,
ChatChannel,
ChatMessage,
ChatScope,
} from "@/types/chat";
import type { PaginatedResponse } from "@/types/common";
// ---------------------------------------------------------------------------
// Query parameters
// ---------------------------------------------------------------------------
export interface GetChatThreadsParams {
/** Filter by channel ID */
channelId?: string;
/** Filter by scope kind */
scopeKind?: ChatScope['kind'];
/** Filter by scope entity ID */
entityId?: string;
/** Include archived threads */
includeArchived?: boolean;
/** Pagination limit */
limit?: number;
/** Pagination offset */
offset?: number;
}
export interface CreateChatThreadPayload {
/** Conversation scope */
scope: ChatScope;
/** Optional initial message content */
initialMessage?: string;
/** Optional thread title (auto-generated if omitted) */
title?: string;
}
export interface SendChatMessagePayload {
/** Message content (Markdown) */
content: string;
/** Optional graph reference */
graphRef?: {
nodeId: string;
nodeType: string;
label: string;
};
}
// ---------------------------------------------------------------------------
// Endpoints
// ---------------------------------------------------------------------------
/**
* List chat threads with optional filtering and pagination.
*/
export async function getChatThreads(
params?: GetChatThreadsParams,
): Promise<PaginatedResponse<ChatThread>> {
const { data } = await client.get<PaginatedResponse<ChatThread>>(
"/chat/threads",
{ params },
);
return data;
}
/**
* Retrieve a single chat thread by ID, including all messages.
*/
export async function getChatThread(id: string): Promise<ChatThread> {
const { data } = await client.get<ChatThread>(`/chat/threads/${id}`);
return data;
}
/**
* Create a new chat thread with a given scope.
*/
export async function createChatThread(
payload: CreateChatThreadPayload,
): Promise<ChatThread> {
const { data } = await client.post<ChatThread>("/chat/threads", payload);
return data;
}
/**
* Send a message in an existing chat thread.
* The backend routes the message to the appropriate domain agent.
*/
export async function sendChatMessage(
threadId: string,
payload: SendChatMessagePayload,
): Promise<ChatMessage> {
const { data } = await client.post<ChatMessage>(
`/chat/threads/${threadId}/messages`,
payload,
);
return data;
}
/**
* List available chat channels.
*/
export async function getChatChannels(): Promise<ChatChannel[]> {
const { data } = await client.get<{ channels: ChatChannel[] }>(
"/chat/channels",
);
return data.channels;
}
Hooks
src/hooks/use-chat.ts
Follows use-approvals.ts as the model: TanStack Query for data fetching, mutations with targeted cache invalidation.
import {
useQuery,
useMutation,
useQueryClient,
type UseQueryOptions,
} from "@tanstack/react-query";
import {
getChatThreads,
getChatThread,
getChatChannels,
createChatThread,
sendChatMessage,
type GetChatThreadsParams,
type CreateChatThreadPayload,
type SendChatMessagePayload,
} from "@/api/endpoints/chat";
import type { PaginatedResponse } from "@/types/common";
import type { ChatThread, ChatChannel, ChatMessage } from "@/types/chat";
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/**
* Fetch chat threads with optional filtering. Refetches every 30 seconds.
*/
export function useChatThreads(
params?: GetChatThreadsParams,
options?: Partial<UseQueryOptions<PaginatedResponse<ChatThread>>>,
) {
return useQuery<PaginatedResponse<ChatThread>>({
queryKey: ["chat-threads", params],
queryFn: () => getChatThreads(params),
staleTime: 30_000,
refetchInterval: 30_000,
...options,
});
}
/**
* Fetch a single chat thread by ID, including all messages.
*/
export function useChatThread(
id: string | undefined,
options?: Partial<UseQueryOptions<ChatThread>>,
) {
return useQuery<ChatThread>({
queryKey: ["chat-thread", id],
queryFn: () => getChatThread(id!),
enabled: !!id,
staleTime: 10_000,
...options,
});
}
/**
* Fetch available chat channels.
*/
export function useChatChannels(
options?: Partial<UseQueryOptions<ChatChannel[]>>,
) {
return useQuery<ChatChannel[]>({
queryKey: ["chat-channels"],
queryFn: getChatChannels,
staleTime: 60_000,
...options,
});
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
/**
* Create a new chat thread. Invalidates the thread list on success.
*/
export function useCreateChatThread() {
const queryClient = useQueryClient();
return useMutation<ChatThread, Error, CreateChatThreadPayload>({
mutationFn: createChatThread,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat-threads"] });
queryClient.invalidateQueries({ queryKey: ["chat-channels"] });
},
});
}
/**
* Send a message in a chat thread. Invalidates the specific thread on success.
*/
export function useSendChatMessage() {
const queryClient = useQueryClient();
return useMutation<
ChatMessage,
Error,
{ threadId: string; payload: SendChatMessagePayload }
>({
mutationFn: ({ threadId, payload }) =>
sendChatMessage(threadId, payload),
onSuccess: (_data, { threadId }) => {
queryClient.invalidateQueries({ queryKey: ["chat-thread", threadId] });
queryClient.invalidateQueries({ queryKey: ["chat-threads"] });
},
});
}
Zustand Store
src/store/chat-store.ts
Manages ephemeral chat UI state that does not belong in the server-state cache (TanStack Query). Streaming content and typing indicators update at high frequency and are handled imperatively.
import { create } from "zustand";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ChatStoreState {
/** Whether the persistent chat sidebar is open. */
sidebarOpen: boolean;
/** ID of the thread currently displayed in the sidebar. */
activeSidebarThreadId: string | null;
/**
* Streaming content accumulator, keyed by message ID.
* Each entry holds the concatenated chunks received so far.
*/
streamingContent: Record<string, string>;
/**
* Set of thread IDs where an agent is currently typing.
*/
typingThreadIds: Set<string>;
}
export interface ChatStoreActions {
/** Open the sidebar, optionally jumping to a specific thread. */
openSidebar: (threadId?: string) => void;
/** Close the sidebar. */
closeSidebar: () => void;
/** Append a streaming chunk to a message's accumulator. */
appendStreamChunk: (messageId: string, chunk: string) => void;
/** Clear streaming content for a message (called when streaming completes). */
clearStreamContent: (messageId: string) => void;
/** Set the typing indicator for a thread. */
setAgentTyping: (threadId: string, isTyping: boolean) => void;
}
export type ChatStore = ChatStoreState & ChatStoreActions;
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useChatStore = create<ChatStore>()((set) => ({
// -- state defaults -------------------------------------------------------
sidebarOpen: false,
activeSidebarThreadId: null,
streamingContent: {},
typingThreadIds: new Set(),
// -- actions --------------------------------------------------------------
openSidebar: (threadId) =>
set({
sidebarOpen: true,
...(threadId ? { activeSidebarThreadId: threadId } : {}),
}),
closeSidebar: () =>
set({ sidebarOpen: false }),
appendStreamChunk: (messageId, chunk) =>
set((state) => ({
streamingContent: {
...state.streamingContent,
[messageId]: (state.streamingContent[messageId] ?? "") + chunk,
},
})),
clearStreamContent: (messageId) =>
set((state) => {
const { [messageId]: _, ...rest } = state.streamingContent;
return { streamingContent: rest };
}),
setAgentTyping: (threadId, isTyping) =>
set((state) => {
const next = new Set(state.typingThreadIds);
if (isTyping) {
next.add(threadId);
} else {
next.delete(threadId);
}
return { typingThreadIds: next };
}),
}));
Dashboard Integration
Five integration points embed chat into the existing Dashboard pages.
Persistent Sidebar — ChatSidebar
A Sheet-based overlay mounted as a sibling of the main content area in root-layout.tsx. The MessageSquare icon in the topbar replaces the placeholder Bell notification icon, toggling the sidebar open/closed.
// src/components/chat/chat-sidebar.tsx
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useChatStore } from "@/store/chat-store";
import { ChatPanel } from "./chat-panel";
import { useChatThreads, useChatThread } from "@/hooks/use-chat";
export function ChatSidebar() {
const sidebarOpen = useChatStore((s) => s.sidebarOpen);
const closeSidebar = useChatStore((s) => s.closeSidebar);
const activeThreadId = useChatStore((s) => s.activeSidebarThreadId);
const { data: threads } = useChatThreads();
const { data: activeThread } = useChatThread(activeThreadId ?? undefined);
return (
<Sheet open={sidebarOpen} onOpenChange={(open) => !open && closeSidebar()}>
<SheetContent side="right" className="w-[400px] sm:w-[480px] p-0">
<SheetHeader className="px-4 py-3 border-b">
<SheetTitle>Agent Chat</SheetTitle>
</SheetHeader>
{activeThread ? (
<ChatPanel thread={activeThread} />
) : (
/* Thread list view */
<div className="p-4 space-y-2">
{threads?.data.map((thread) => (
/* Thread list item — click to select */
<div key={thread.id} /* ... */ />
))}
</div>
)}
</SheetContent>
</Sheet>
);
}
Topbar changes — In src/components/layout/topbar.tsx, the Bell icon button is replaced by a MessageSquare icon that toggles the chat sidebar:
import { MessageSquare } from "lucide-react";
import { useChatStore } from "@/store/chat-store";
// Inside Topbar component:
const toggleChatSidebar = useChatStore((s) =>
s.sidebarOpen ? s.closeSidebar : () => s.openSidebar()
);
// Replaces the Bell icon button:
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={toggleChatSidebar} aria-label="Chat">
<MessageSquare className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Agent Chat</TooltipContent>
</Tooltip>
Root layout changes — In src/components/layout/root-layout.tsx, ChatSidebar is mounted as a sibling to the main content area, outside the scroll container.
Approvals Page — ApprovalChatPanel
Replaces the static ApprovalComment[] comments section in pending-approvals-list.tsx. Renders inline below the diff viewer within expanded approval cards.
// src/pages/approvals/components/approval-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads, useCreateChatThread } from "@/hooks/use-chat";
import type { Approval } from "@/types/approval";
interface ApprovalChatPanelProps {
approval: Approval;
}
export function ApprovalChatPanel({ approval }: ApprovalChatPanelProps) {
const { data: threads } = useChatThreads({
scopeKind: "approval",
entityId: approval.id,
});
const createThread = useCreateChatThread();
const thread = threads?.data[0]; // One thread per approval
// Auto-create thread on first render if none exists
// ...
return thread ? (
<ChatPanel thread={thread} compact />
) : (
<div className="text-center py-4 text-sm text-muted-foreground">
Start a conversation about this approval...
</div>
);
}
BOM Page — ComponentChatPanel
Row-level chat action in the BOM table. When an engineer clicks the chat icon on a BOM row, a chat panel opens scoped to that component with the SC (Supply Chain) agent.
// src/pages/bom/components/component-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads } from "@/hooks/use-chat";
import type { BOMEntry } from "@/types/bom";
interface ComponentChatPanelProps {
entry: BOMEntry;
}
export function ComponentChatPanel({ entry }: ComponentChatPanelProps) {
const { data: threads } = useChatThreads({
scopeKind: "bom-entry",
entityId: entry.id,
});
const thread = threads?.data[0];
return thread ? <ChatPanel thread={thread} compact /> : null;
}
Digital Twin Page — NodeChatPanel
Toolbar button in the 3D viewer. When an engineer selects a mesh node and clicks the chat button, a panel opens scoped to that node with the ME (Mechanical) agent.
// src/pages/digital-twin/components/node-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads } from "@/hooks/use-chat";
interface NodeChatPanelProps {
nodeId: string;
nodeLabel: string;
}
export function NodeChatPanel({ nodeId, nodeLabel }: NodeChatPanelProps) {
const { data: threads } = useChatThreads({
scopeKind: "digital-twin-node",
entityId: nodeId,
});
const thread = threads?.data[0];
return thread ? <ChatPanel thread={thread} compact /> : null;
}
Sessions Page — SessionChatPanel
Renders below the trace viewer in the session detail page. The orchestrator chooses the most relevant agent based on the session’s current trace context.
// src/pages/sessions/components/session-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads } from "@/hooks/use-chat";
interface SessionChatPanelProps {
sessionId: string;
}
export function SessionChatPanel({ sessionId }: SessionChatPanelProps) {
const { data: threads } = useChatThreads({
scopeKind: "session",
entityId: sessionId,
});
const thread = threads?.data[0];
return thread ? <ChatPanel thread={thread} /> : null;
}
IDE Integration
IDE extensions share the same WebSocket protocol and message types as the Dashboard, packaged in a shared @metaforge/chat-types npm package.
VS Code Extension — Chat Panel
The VS Code extension renders a chat panel as a webview in the sidebar. It detects file and cursor context automatically to scope conversations.
vscode-extension/
├── src/
│ ├── chat/
│ │ ├── chat-panel.ts # Webview provider
│ │ ├── chat-view.html # Panel HTML template
│ │ ├── context-detector.ts # File/cursor → ChatScope resolution
│ │ └── ws-client.ts # WebSocket connection to Gateway
│ └── extension.ts # Extension entry point
├── package.json
└── tsconfig.json
Context detection — When the engineer has a file open, context-detector.ts resolves it to a ChatScope:
| File Pattern | Scope Kind | Entity Resolution |
|---|---|---|
*.kicad_sch, *.kicad_pcb |
bom-entry |
Parse selected component reference designator |
*.c, *.h, pinmap.json |
session |
Resolve to active firmware session |
*.FCStd, *.step |
digital-twin-node |
Resolve to selected part node |
| Other / no file | project |
Project-level scope |
KiCad Plugin — Chat Widget
The KiCad plugin embeds a chat widget as a Qt panel. When the engineer selects a component on the schematic or PCB, the widget scopes the conversation to that component.
kicad-plugin/
├── chat_widget.py # Qt panel implementation
├── context_resolver.py # Selected component → ChatScope
├── ws_client.py # WebSocket connection to Gateway
└── __init__.py
Shared Types Package
packages/chat-types/
├── src/
│ ├── index.ts # Barrel export
│ ├── chat.ts # ChatMessage, ChatThread, ChatScope, etc.
│ └── events.ts # WebSocket event payloads
├── package.json
└── tsconfig.json
Both the Dashboard and IDE extensions depend on @metaforge/chat-types to ensure message format consistency.
Backend Architecture
Kafka Topic
A new Kafka topic agent.chat carries all chat-related events.
topics:
# ... existing topics ...
- "agent.chat" # Chat messages, typing indicators, thread lifecycle
| Topic | Retention | Rationale |
|---|---|---|
agent.chat |
90 days | Conversation history for context; older threads archived to PostgreSQL |
EventType Additions
Four new values for the EventType enum in digital_twin/events.py:
class EventType(str, Enum):
# ... existing types ...
# Agent Chat Channel
CHAT_MESSAGE_SENT = "chat.message.sent"
CHAT_MESSAGE_CHUNK = "chat.message.chunk"
CHAT_THREAD_CREATED = "chat.thread.created"
CHAT_AGENT_TYPING = "chat.agent.typing"
Temporal Activity — handle_chat_message
When an engineer sends a chat message, the Gateway publishes it to Kafka and triggers a Temporal activity that assembles context, invokes the LLM, and streams the response back.
@activity.defn
async def handle_chat_message(
thread_id: str,
message_id: str,
content: str,
scope: dict,
actor_id: str,
) -> dict:
"""Process an incoming chat message and generate an agent response.
1. Assemble context from Neo4j based on the ChatScope
2. Retrieve relevant knowledge via pgvector RAG
3. Stream LLM response, publishing chunks to Kafka
4. Persist the complete response to PostgreSQL
"""
# 1. Context assembly
context = await assemble_context(scope)
# 2. RAG retrieval
relevant_docs = await pgvector_search(content, scope)
# 3. Stream LLM response
agent_message_id = str(uuid4())
await publish_event(EventType.CHAT_AGENT_TYPING, {
"thread_id": thread_id,
"is_typing": True,
})
full_response = ""
async for chunk in llm_stream(content, context, relevant_docs):
full_response += chunk
await publish_to_kafka("agent.chat", {
"event": "chat.message.chunk",
"data": {
"message_id": agent_message_id,
"thread_id": thread_id,
"chunk": chunk,
"done": False,
},
})
# Final chunk
await publish_to_kafka("agent.chat", {
"event": "chat.message.chunk",
"data": {
"message_id": agent_message_id,
"thread_id": thread_id,
"chunk": "",
"done": True,
},
})
# 4. Persist
await persist_message(thread_id, agent_message_id, full_response)
return {"status": "completed", "message_id": agent_message_id}
PostgreSQL Schema
-- Chat threads
CREATE TABLE chat_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL,
scope_kind TEXT NOT NULL,
scope_entity_id TEXT NOT NULL,
title TEXT NOT NULL,
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_message_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_channel FOREIGN KEY (channel_id)
REFERENCES chat_channels(id) ON DELETE CASCADE
);
CREATE INDEX idx_threads_scope ON chat_threads(scope_kind, scope_entity_id);
CREATE INDEX idx_threads_channel ON chat_threads(channel_id);
CREATE INDEX idx_threads_last_message ON chat_threads(last_message_at DESC);
-- Chat messages
CREATE TABLE chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL,
actor_id TEXT NOT NULL,
actor_kind TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'sent',
graph_ref_node TEXT,
graph_ref_type TEXT,
graph_ref_label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT fk_thread FOREIGN KEY (thread_id)
REFERENCES chat_threads(id) ON DELETE CASCADE
);
CREATE INDEX idx_messages_thread ON chat_messages(thread_id, created_at);
CREATE INDEX idx_messages_actor ON chat_messages(actor_id);
Component Specifications
ChatPanel — Base Composite
The primary chat UI component used by all integration points. Renders a message list, typing indicator, and message composer.
// src/components/chat/chat-panel.tsx
interface ChatPanelProps {
/** The thread to display */
thread: ChatThread;
/** When true, renders in a compact layout for inline embedding */
compact?: boolean;
}
| Sub-component | Description |
|---|---|
| Message list | Scrollable area with ChatMessageBubble instances, auto-scrolls on new messages |
TypingIndicator |
Shown when an agent is composing a response |
ChatComposer |
Text input with send button at the bottom |
ChatMessageBubble — Message Display
Renders a single message with actor-appropriate styling.
| Actor Kind | Alignment | Background | Avatar |
|---|---|---|---|
user |
Right | bg-primary text-primary-foreground |
User initial |
agent |
Left | bg-muted |
Agent code badge |
system |
Center | bg-muted/50 text-muted-foreground italic |
None |
Markdown content is rendered using a lightweight Markdown renderer. Graph references render as clickable badges that navigate to the referenced entity.
TypingIndicator — Animated Dots
Three animated dots displayed when typingThreadIds in the chat store contains the current thread ID.
// src/components/chat/typing-indicator.tsx
export function TypingIndicator({ agentName }: { agentName: string }) {
return (
<div className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground">
<span>{agentName} is typing</span>
<span className="flex gap-0.5">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" />
</span>
</div>
);
}
ChatSidebar — Sheet Overlay
See Dashboard Integration — Persistent Sidebar above. Uses shadcn/ui Sheet component, mounted in root-layout.tsx.
ChatComposer — Message Input
Textarea with send button. Supports Enter to send (with Shift+Enter for newlines).
// src/components/chat/chat-composer.tsx
interface ChatComposerProps {
onSend: (content: string) => void;
disabled?: boolean;
placeholder?: string;
}
ApprovalChatPanel — Thin Wrapper
Wraps ChatPanel with approval-scoped thread resolution. See Approvals Page above.
Context Assembly
When the backend processes a chat message, it assembles context from Neo4j based on the ChatScope.kind. This context is included in the LLM prompt to ground the agent’s responses.
ChatScope.kind |
Neo4j Context Assembled |
|---|---|
session |
Session traces, produced artifacts, recent graph mutations from this session |
approval |
Proposed diffs, approval description, originating session context, constraint evaluation results |
bom-entry |
Component properties (MPN, manufacturer, specs), alternate parts, supply chain risk score, graph neighbors (connected requirements, tests) |
digital-twin-node |
Node properties, connected requirements (via TRACES_TO), simulation results, constraint status |
project |
Recent sessions (last 10), gate readiness scores (EVT/DVT/PVT), open approvals count, unresolved constraint violations |
The RAG layer (pgvector) supplements graph context with relevant passages from ingested knowledge documents (datasheets, standards, design guides).
Message Lifecycle
sequenceDiagram
participant ENG as Engineer
participant UI as Dashboard / IDE
participant WS as WebSocket Gateway
participant K as Kafka
participant TMP as Temporal
participant NEO as Neo4j
participant LLM as LLM Provider
participant PG as PostgreSQL
ENG->>UI: Type message & send
UI->>WS: WebSocket frame<br/>(chat.message.send)
WS->>K: Publish to "agent.chat"
WS->>PG: Persist user message
WS-->>UI: chat.message.created<br/>(echo back)
K->>TMP: Trigger handle_chat_message
TMP->>NEO: Assemble context<br/>(scope-based query)
NEO-->>TMP: Graph context
TMP->>LLM: Stream prompt<br/>(context + message)
loop Streaming chunks
LLM-->>TMP: Token chunk
TMP->>K: Publish chat.message.chunk
K->>WS: Forward to subscribers
WS-->>UI: chat.message.chunk
UI->>UI: appendStreamChunk()
end
TMP->>PG: Persist agent response
TMP->>K: Publish chat.message.created
K->>WS: Forward
WS-->>UI: chat.message.created<br/>(final message)
Component Hierarchy
graph TD
RL["root-layout.tsx"] --> CS["ChatSidebar<br/>(Sheet overlay)"]
CS --> CP1["ChatPanel"]
AP["approvals-page"] --> ACP["ApprovalChatPanel"]
ACP --> CP2["ChatPanel"]
BP["bom-page"] --> CCP["ComponentChatPanel"]
CCP --> CP3["ChatPanel"]
DT["digital-twin-page"] --> NCP["NodeChatPanel"]
NCP --> CP4["ChatPanel"]
SP["sessions-page"] --> SCP["SessionChatPanel"]
SCP --> CP5["ChatPanel"]
CP1 --> MB["ChatMessageBubble"]
CP1 --> TI["TypingIndicator"]
CP1 --> CC["ChatComposer"]
CP2 --> MB
CP3 --> MB
CP4 --> MB
CP5 --> MB
style CS fill:#4a6fa5,color:#fff
style ACP fill:#e67e22,color:#fff
style CCP fill:#27ae60,color:#fff
style NCP fill:#8e44ad,color:#fff
style SCP fill:#2c3e50,color:#fff
WebSocket Streaming Flow
flowchart LR
LLM["LLM Provider"] -->|"token chunks"| TMP["Temporal Activity"]
TMP -->|"chat.message.chunk"| K["Kafka<br/>agent.chat"]
K -->|"WebSocket broadcast"| GW["Gateway<br/>WebSocket Server"]
GW -->|"ws frame"| DASH["Dashboard<br/>chat-store.appendStreamChunk()"]
GW -->|"ws frame"| IDE["IDE Extension<br/>webview update"]
TMP -->|"chat.message.created<br/>(final)"| K
K -->|"invalidation"| GW
GW -->|"TanStack Query<br/>cache invalidation"| DASH
style LLM fill:#e74c3c,color:#fff
style K fill:#2c3e50,color:#fff
style GW fill:#3498db,color:#fff
style DASH fill:#27ae60,color:#fff
style IDE fill:#8e44ad,color:#fff
Relationship to Existing Features
| Feature | Relationship |
|---|---|
| Assistant Mode | Chat extends the assistant paradigm — instead of only surfacing notifications, engineers can now ask follow-up questions about constraint violations, drift reports, and ingest results |
| Approvals | ApprovalChatPanel replaces the static ApprovalComment[] comments section, enabling real-time dialogue with the proposing agent before approving or rejecting |
| Sessions | SessionChatPanel provides a conversational interface alongside the trace viewer, allowing engineers to ask agents about specific trace steps |
| AI Memory | Chat message history feeds into the knowledge layer — agent responses and engineer feedback become searchable context for future conversations |
| Event Sourcing | Chat events flow through the same Kafka infrastructure, using 4 new EventType values and the agent.chat topic |
| Constraint Engine | When a constraint violation is surfaced via chat, the engineer can ask the agent to explain the violation, suggest fixes, or request a re-evaluation |
| IDE Assistants | Chat shares the WebSocket protocol with IDE extensions, enabling the same conversational interface in VS Code and KiCad |
Related Documents
| Document | Description |
|---|---|
| Event Sourcing | Kafka topics, EventType enum, and event pipeline — defines the agent.chat topic and 4 new event types used by chat |
| Assistant Mode | Dual-mode operation architecture — chat extends the assistant paradigm with bidirectional dialogue |
| Type Definitions | Dashboard type conventions — chat types follow the same patterns |
| Custom Hooks | TanStack Query hook patterns — chat hooks follow use-approvals.ts as model |
| State Management | Zustand store patterns — chat-store follows existing conventions |
| API Client & Endpoints | Axios client pattern — chat endpoints follow the same structure |
| Layout Components | Root layout and topbar — ChatSidebar is mounted in root layout, MessageSquare replaces Bell in topbar |
| Approvals Workflow Page | Approval page — ApprovalChatPanel replaces static comments section |
Document Version: v1.0 Last Updated: 2026-03-03 Status: Technical Specification