Shared Chat Component Design
Date: 2026-04-24
Status: Approved
Context
All three generation panels (Screens, Components, Themes) have their own ad-hoc chat implementations. ComponentsPanel and ScreensPanel have full multi-turn streaming chat; ThemesPanel has only a one-shot prompt textarea. None support thinking blocks, vision (images), or cross-asset context injection.
This spec defines a shared chat system that:
- Unifies the chat UX across all three panels
- Upgrades ThemesPanel to full multi-turn persistent chat
- Adds
<think>...</think>block parsing with collapsible reasoning UI (for Ollama thinking models like DeepSeek-R1, Qwen3) - Adds full vision API support (base64 image messages to Ollama)
- Adds
@mentionpicker and drag-drop for referencing project assets (components, themes, screens) as context
Architecture
Store: src/stores/chatStore.ts
New Zustand store (in-memory only; disk persistence still goes to chat.json files):
interface ChatState {
messages: ChatMessage[]
isStreaming: boolean
}
interface ChatStore {
chats: Record<string, ChatState> // keyed by entity ID (e.g. "comp-abc", "theme-main")
getChat: (id: string) => ChatState
setMessages: (id: string, messages: ChatMessage[]) => void
setStreaming: (id: string, streaming: boolean) => void
appendChunk: (id: string, chunk: string) => void // updates last assistant message in-place
clearChat: (id: string) => void
}
appendChunk mutates the last message’s content string directly — avoids full array replacement on every stream chunk, preventing re-renders of earlier messages.
Data Model
// src/types/chat.ts (new file)
export interface ChatMessage {
role: "user" | "assistant"
content: string // raw text — sent to API, persisted to chat.json
images?: string[] // base64-encoded strings for vision messages
blocks?: MessageBlock[] // derived at display time; NOT persisted
}
export type MessageBlock =
| { type: "thinking"; content: string; collapsed: boolean }
| { type: "text"; content: string }
export interface MentionAsset {
id: string
type: "component" | "theme" | "screen"
name: string
path: string
code: string // resolved content injected into message context on send
}
blocks is parsed from content each time a message is rendered — <think>...</think> → thinking block, everything else → text block. Not stored to disk.
On send, mention assets are serialized as hidden context fences prepended to the user’s visible text:
<!-- @ComponentName -->
```tsx
// component code here
User’s actual message here
The API sees the full context; the UI shows only chips + the user's typed text.
---
### Hook: `src/hooks/useChat.ts`
```ts
interface UseChatOptions {
entityId: string // key into chatStore
chatPath: string // path to chat.json for cold-start load + persistence
systemPrompt: string // injected as first system message on every send
onOutput?: (content: string) => void // called when assistant turn completes
}
interface UseChatReturn {
messages: ChatMessage[]
input: string
setInput: (v: string) => void
isStreaming: boolean
sendMessage: () => Promise<void>
clearChat: () => void
// attachments
attachments: AttachmentFile[]
addAttachment: (file: File) => Promise<void>
removeAttachment: (index: number) => void
// mentions
mentions: MentionAsset[]
addMention: (asset: MentionAsset) => void
removeMention: (id: string) => void
}
Cold start: On mount, if chatStore.chats[entityId] has no messages, load from chatPath via readFile. Populates the store. Subsequent mounts (e.g. switching panels and back) skip the file read.
Send flow:
- Build user
ChatMessage:content = serializeMentions(mentions) + input,images = attachments.map(a => a.base64) - Append to store via
setMessages - Clear
input,attachments,mentions - Append empty assistant message placeholder
- Open
Channel<CompletionEvent>, callgenerateCompletionStream(model, fullMessages, host, apiKey, channel) Chunkevents →appendChunk(entityId, text)Done→setStreaming(false), callonOutput(finalContent), persist tochatPath
Thinking parsing happens in appendChunk: tracks an inThink boolean, routes characters to the thinking buffer or text buffer. The final content stored is the raw stream (with <think> tags); blocks are derived on render.
Presentational Components: src/components/chat/
| File | Responsibility |
|---|---|
MessageList.tsx |
Renders message array; parses blocks from each message’s content; owns auto-scroll ref |
ThinkingBlock.tsx |
Collapsible <think> section; collapsed by default; “Reasoning…” toggle header |
ChatInput.tsx |
Textarea + send; detects @ → opens MentionPicker; handles paste/drop for images; renders MentionChip + AttachmentChip above input |
MentionPicker.tsx |
Floating dropdown anchored at cursor; loads components/themes/screens from project filesystem; fuzzy-filters on text after @; keyboard-navigable (↑↓ Enter Esc) |
AttachmentChip.tsx |
Image thumbnail + filename + remove × |
MentionChip.tsx |
Colored chip: asset type icon (component/theme/screen) + name + remove × |
index.ts |
Re-exports all six |
Backend: Vision Support
src/lib/ipc.ts — extend Message:
export interface Message {
role: string
content: string
images?: string[] // ← new
}
src-tauri/src/lib.rs — extend Message struct and chat_completion_ollama:
#[derive(Serialize, Deserialize)]
struct Message {
role: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
images: Option<Vec<String>>,
}
Ollama accepts images as a top-level field on each message object. No other provider changes needed (Ollama-only scope).
Panel Changes
ThemesPanel (src/panels/ThemesPanel.tsx)
- Replace
promptstring state +isGeneratingwithuseChat({ entityId: themeId, chatPath, systemPrompt, onOutput: applyCss }) <MessageList>+<ChatInput>slot into the existing prompt input area- Layout unchanged — CSS editor and preview pane positions stay exactly as-is
- Chat persisted to
themes/{themeId}/chat.json - System prompt: existing
getThemeSystemPrompt(framework)+ settings override
ComponentsPanel (src/panels/ComponentsPanel.tsx)
- Replace inline chat state with
useChat({ entityId: componentId, chatPath, systemPrompt, onOutput: applyCode }) - Replace inline
<MessageList>-equivalent UI with shared<MessageList>+<ChatInput> - Existing split-pane layout preserved
ScreensPanel (src/panels/ScreensPanel.tsx)
- Same migration: replace inline chat state with
useChat - Existing image attachment code removed (handled by
useChat+addAttachment) - Existing layout preserved
Drag-Drop from Project Explorer
src/panels/RunnerPanel.tsx — file tree items:
- Add
draggable+onDragStartto individual file items dragstartsetsdataTransferpayload:{ filePath, assetType }(derived from file extension:.tsx→ component,.css→ theme)
<ChatInput> — drop zone:
onDragOver+onDrophandlers.tsx/.cssdrops: read file content viareadFile(path), resolve asMentionAsset, calladdMention()- Image extension drops (
.png,.jpg,.webp, etc.): read as base64, calladdAttachment() - Visual drop highlight on
dragover
Files Created / Modified
New:
src/types/chat.tssrc/stores/chatStore.tssrc/hooks/useChat.tssrc/components/chat/MessageList.tsxsrc/components/chat/ThinkingBlock.tsxsrc/components/chat/ChatInput.tsxsrc/components/chat/MentionPicker.tsxsrc/components/chat/AttachmentChip.tsxsrc/components/chat/MentionChip.tsxsrc/components/chat/index.ts
Modified:
src/panels/ThemesPanel.tsxsrc/panels/ComponentsPanel.tsxsrc/panels/ScreensPanel.tsxsrc/panels/RunnerPanel.tsx(drag-drop on file items)src/lib/ipc.ts(Message type)src-tauri/src/lib.rs(Message struct + Ollama vision)
Verification
- ThemesPanel chat: Open a theme → type a prompt → response streams in, CSS updates in editor + preview. Switch away and back — messages still present (store). Reload app — messages reload from
chat.json. - Thinking blocks: Select a thinking model (DeepSeek-R1, Qwen3) → send message → “Reasoning…” section appears collapsed above response text. Click to expand/collapse.
- Vision: Paste or drop an image into chat input → thumbnail chip appears → send → Ollama vision model receives base64 image.
- @mention: Type
@→ dropdown shows project components/themes/screens → select one → chip appears → send → model receives the asset’s code as context. - Drag-drop: Drag a
.tsxfile from the Project Explorer → drop into chat input → mention chip appears with component name. - No regressions: ComponentsPanel and ScreensPanel chat still works; ThemesPanel layout unchanged;
bunx tsc --noEmitpasses.