From 0874d3a62097223f33d6d287d3cba24ee084542c Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 6 Apr 2026 21:02:44 +0100 Subject: [PATCH 1/7] feat(config): parse contextBlocks from .mitzo.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add contextBlocks field to RepoConfig — a Record mapping block names to resolved file paths. Relative paths are resolved against REPO_PATH, absolute paths pass through unchanged. Co-Authored-By: Claude Opus 4.6 --- server/__tests__/repo-config.test.ts | 54 ++++++++++++++++++++++++++++ server/repo-config.ts | 15 ++++++++ 2 files changed, 69 insertions(+) diff --git a/server/__tests__/repo-config.test.ts b/server/__tests__/repo-config.test.ts index e285434..0e9ac42 100644 --- a/server/__tests__/repo-config.test.ts +++ b/server/__tests__/repo-config.test.ts @@ -202,6 +202,60 @@ describe('loadRepoConfig', () => { expect(config.repos).toEqual({ valid: validDir }); }); + it('parses contextBlocks from .mitzo.json', () => { + const data = { + contextBlocks: { + Workflow: 'context/workflow.md', + Org: 'context/org.md', + }, + }; + writeFileSync(join(TMP_DIR, '.mitzo.json'), JSON.stringify(data)); + + const config = loadRepoConfig(TMP_DIR); + expect(config.contextBlocks).toEqual({ + Workflow: join(TMP_DIR, 'context/workflow.md'), + Org: join(TMP_DIR, 'context/org.md'), + }); + }); + + it('returns empty contextBlocks when not specified', () => { + const data = { quickActions: [] }; + writeFileSync(join(TMP_DIR, '.mitzo.json'), JSON.stringify(data)); + + const config = loadRepoConfig(TMP_DIR); + expect(config.contextBlocks).toEqual({}); + }); + + it('filters out non-string contextBlock values', () => { + const data = { + contextBlocks: { + Valid: 'context/valid.md', + Invalid: 42, + AlsoInvalid: null, + }, + }; + writeFileSync(join(TMP_DIR, '.mitzo.json'), JSON.stringify(data)); + + const config = loadRepoConfig(TMP_DIR); + expect(config.contextBlocks).toEqual({ + Valid: join(TMP_DIR, 'context/valid.md'), + }); + }); + + it('resolves absolute contextBlock paths without joining', () => { + const data = { + contextBlocks: { + Absolute: '/absolute/path/file.md', + }, + }; + writeFileSync(join(TMP_DIR, '.mitzo.json'), JSON.stringify(data)); + + const config = loadRepoConfig(TMP_DIR); + expect(config.contextBlocks).toEqual({ + Absolute: '/absolute/path/file.md', + }); + }); + it('filters out quickActions with missing required fields', () => { const data = { quickActions: [ diff --git a/server/repo-config.ts b/server/repo-config.ts index 194cedc..fa8a9c7 100644 --- a/server/repo-config.ts +++ b/server/repo-config.ts @@ -30,6 +30,7 @@ export interface RepoConfig { inboxPath: string; resolvedInboxPath: string; repos: Record; + contextBlocks: Record; } const EMPTY_CONFIG: RepoConfig = { @@ -42,6 +43,7 @@ const EMPTY_CONFIG: RepoConfig = { inboxPath: '', resolvedInboxPath: '', repos: {}, + contextBlocks: {}, }; function isValidQuickAction(item: unknown): item is QuickAction { @@ -128,6 +130,18 @@ export function loadRepoConfig(repoPath: string): RepoConfig { } } + const contextBlocks: Record = {}; + if ( + obj.contextBlocks && + typeof obj.contextBlocks === 'object' && + !Array.isArray(obj.contextBlocks) + ) { + for (const [name, path] of Object.entries(obj.contextBlocks as Record)) { + if (typeof path !== 'string') continue; + contextBlocks[name] = path.startsWith('/') ? path : join(repoPath, path); + } + } + return { quickActions, venvPaths, @@ -138,5 +152,6 @@ export function loadRepoConfig(repoPath: string): RepoConfig { inboxPath, resolvedInboxPath, repos, + contextBlocks, }; } From 369d7d6e52636cec270811d5993bcca2ae8aab64 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 6 Apr 2026 21:04:11 +0100 Subject: [PATCH 2/7] feat(api): expose contextBlocks with file sizes in /api/config Returns { [name]: { path, sizeBytes } } for each configured context block. Size computed via statSync (0 if file not found). Co-Authored-By: Claude Opus 4.6 --- server/__tests__/routes.test.ts | 5 ++++- server/app.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/server/__tests__/routes.test.ts b/server/__tests__/routes.test.ts index e671673..a91835e 100644 --- a/server/__tests__/routes.test.ts +++ b/server/__tests__/routes.test.ts @@ -32,6 +32,7 @@ vi.mock('../chat.js', () => { inboxPath: 'mgmt_lib/inbox', resolvedInboxPath: pjoin(repo, 'mgmt_lib/inbox'), repos: {}, + contextBlocks: {}, }, getMcpServerNames: vi.fn().mockReturnValue(['test-mcp']), AVAILABLE_MODELS: [{ id: 'test-model', label: 'Test', desc: 'Test model' }], @@ -291,12 +292,14 @@ describe('config routes', () => { expect(res.body[0]).toHaveProperty('id'); }); - it('GET /api/config — returns config', async () => { + it('GET /api/config — returns config with contextBlocks', async () => { const res = await request(app).get('/api/config').set('Cookie', authCookie); expect(res.status).toBe(200); expect(res.body).toHaveProperty('repoPath'); expect(res.body).toHaveProperty('mcpServers'); expect(res.body).toHaveProperty('quickActions'); + expect(res.body).toHaveProperty('contextBlocks'); + expect(typeof res.body.contextBlocks).toBe('object'); }); it('GET /api/worktrees — returns worktree list', async () => { diff --git a/server/app.ts b/server/app.ts index 2746e9f..59a1ec3 100644 --- a/server/app.ts +++ b/server/app.ts @@ -280,10 +280,21 @@ app.get('/api/config', (_req, res) => { ...a, cwd: a.cwd ? join(BASE_REPO, a.cwd) : undefined, })); + const contextBlocks: Record = {}; + for (const [name, path] of Object.entries(repoConfig.contextBlocks)) { + let sizeBytes = 0; + try { + sizeBytes = statSync(path).size; + } catch { + // File may not exist yet — show 0 size + } + contextBlocks[name] = { path, sizeBytes }; + } res.json({ repoPath: BASE_REPO, mcpServers: getMcpServerNames(), quickActions: actions, + contextBlocks, }); }); From 74e55a68fd553b874ab9835646e42fe1abc027d0 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 6 Apr 2026 21:05:34 +0100 Subject: [PATCH 3/7] feat(chat): context block prompt assembly with preamble and separator - Add contextBlocks to WS SendMessage and InterruptMessage schemas - Extend assemblePrompt() to read context block files, wrap in tags with preamble, and separate with ---CONTEXT_END--- - Gracefully skips unknown names and missing files Co-Authored-By: Claude Opus 4.6 --- server/__tests__/assemble-prompt.test.ts | 127 +++++++++++++++++++++++ server/chat.ts | 39 ++++++- server/ws-schemas.ts | 2 + 3 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 server/__tests__/assemble-prompt.test.ts diff --git a/server/__tests__/assemble-prompt.test.ts b/server/__tests__/assemble-prompt.test.ts new file mode 100644 index 0000000..1575d73 --- /dev/null +++ b/server/__tests__/assemble-prompt.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; + +const TMP_DIR = join(import.meta.dirname, '..', '..', '.test-assemble-prompt'); + +// Mock repo-config to provide contextBlocks +const mockContextBlocks: Record = {}; + +vi.mock('../repo-config.js', () => ({ + loadRepoConfig: vi.fn(() => ({ + contextBlocks: mockContextBlocks, + quickActions: [], + venvPaths: [], + resolvedVenvPaths: [], + allowedPaths: [], + roots: [], + toolTierOverrides: {}, + inboxPath: '', + resolvedInboxPath: '', + repos: {}, + })), +})); + +// Must import after mock setup +const { assemblePrompt } = await import('../chat.js'); + +beforeEach(() => { + mkdirSync(TMP_DIR, { recursive: true }); + // Clear mock context blocks + for (const key of Object.keys(mockContextBlocks)) delete mockContextBlocks[key]; +}); + +afterEach(() => { + rmSync(TMP_DIR, { recursive: true, force: true }); +}); + +describe('assemblePrompt — context blocks', () => { + it('returns plain prompt when no context blocks provided', () => { + const result = assemblePrompt('Hello Claude', TMP_DIR); + expect(result).toBe('Hello Claude'); + }); + + it('returns plain prompt when contextBlocks is empty array', () => { + const result = assemblePrompt('Hello Claude', TMP_DIR, undefined, []); + expect(result).toBe('Hello Claude'); + }); + + it('injects context blocks with preamble and separator', () => { + const filePath = join(TMP_DIR, 'workflow.md'); + writeFileSync(filePath, '# Workflow\nStep 1: Do the thing'); + mockContextBlocks['Workflow'] = filePath; + + const result = assemblePrompt('Explain this', TMP_DIR, undefined, ['Workflow']); + + expect(result).toContain('The user has attached the following reference files'); + expect(result).toContain(''); + expect(result).toContain('---CONTEXT_END---'); + expect(result).toContain('Explain this'); + // User message must come after separator + const sepIndex = result.indexOf('---CONTEXT_END---'); + const msgIndex = result.indexOf('Explain this'); + expect(msgIndex).toBeGreaterThan(sepIndex); + }); + + it('injects multiple context blocks', () => { + const file1 = join(TMP_DIR, 'workflow.md'); + const file2 = join(TMP_DIR, 'org.md'); + writeFileSync(file1, 'Workflow content'); + writeFileSync(file2, 'Org content'); + mockContextBlocks['Workflow'] = file1; + mockContextBlocks['Org'] = file2; + + const result = assemblePrompt('Question', TMP_DIR, undefined, ['Workflow', 'Org']); + + expect(result).toContain(' { + const filePath = join(TMP_DIR, 'workflow.md'); + writeFileSync(filePath, 'Workflow content'); + mockContextBlocks['Workflow'] = filePath; + + const result = assemblePrompt('Question', TMP_DIR, undefined, ['Workflow', 'NonExistent']); + + expect(result).toContain('Workflow content'); + expect(result).not.toContain('NonExistent'); + }); + + it('skips context blocks whose files are missing', () => { + mockContextBlocks['Missing'] = join(TMP_DIR, 'does-not-exist.md'); + + const result = assemblePrompt('Question', TMP_DIR, undefined, ['Missing']); + + // Should fall back to plain prompt since no blocks could be read + expect(result).toBe('Question'); + }); + + it('includes source path in context tag', () => { + const filePath = join(TMP_DIR, 'workflow.md'); + writeFileSync(filePath, 'content'); + mockContextBlocks['Workflow'] = filePath; + + const result = assemblePrompt('Q', TMP_DIR, undefined, ['Workflow']); + + expect(result).toContain(`source="${filePath}"`); + }); + + it('works alongside images', () => { + const filePath = join(TMP_DIR, 'workflow.md'); + writeFileSync(filePath, 'Workflow content'); + mockContextBlocks['Workflow'] = filePath; + + const images = [{ data: 'dGVzdA==', mediaType: 'image/png' }]; + const result = assemblePrompt('Describe', TMP_DIR, images, ['Workflow']); + + // Should have both context blocks and image references + expect(result).toContain(', + contextBlocks?: string[], ): string { - if (!images?.length) return prompt; - const paths = stageImages(cwd, images); - const imageRefs = paths.map((p) => `- ${p}`).join('\n'); - return `${prompt}\n\nI've attached ${paths.length} image(s). Read them using the Read tool:\n${imageRefs}`; + let result = prompt; + + // Inject context blocks before the user's message + if (contextBlocks?.length) { + const blocks: string[] = []; + for (const name of contextBlocks) { + const filePath = repoConfig.contextBlocks[name]; + if (!filePath) continue; + let content: string; + try { + content = readFileSync(filePath, 'utf-8'); + } catch { + log.warn('context block file not found', { name, path: filePath }); + continue; + } + blocks.push(`\n${content}\n`); + } + if (blocks.length > 0) { + const preamble = + 'The user has attached the following reference files for this message.\nUse them to inform your response.'; + result = `${preamble}\n\n${blocks.join('\n\n')}\n\n---CONTEXT_END---\n${result}`; + } + } + + // Append image references + if (images?.length) { + const paths = stageImages(cwd, images); + const imageRefs = paths.map((p) => `- ${p}`).join('\n'); + result = `${result}\n\nI've attached ${paths.length} image(s). Read them using the Read tool:\n${imageRefs}`; + } + + return result; } function stageImages(cwd: string, images: Array<{ data: string; mediaType: string }>): string[] { diff --git a/server/ws-schemas.ts b/server/ws-schemas.ts index 7deb30c..93099e8 100644 --- a/server/ws-schemas.ts +++ b/server/ws-schemas.ts @@ -21,12 +21,14 @@ export const SendMessage = z.object({ extraTools: z.string().optional(), worktree: z.boolean().optional(), images: z.array(ImageSchema).optional(), + contextBlocks: z.array(z.string()).optional(), }); export const InterruptMessage = z.object({ type: z.literal('interrupt'), prompt: z.string().min(1), images: z.array(ImageSchema).optional(), + contextBlocks: z.array(z.string()).optional(), }); export const StopMessage = z.object({ From 3093d3e3f3c5de733fdb7ca2a615384899703742 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 6 Apr 2026 21:08:50 +0100 Subject: [PATCH 4/7] feat(chat): wire contextBlocks through startChat, sendToChat, interruptChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass contextBlocks from WS messages through to assemblePrompt at all call sites — skill resolution, passthrough, and interrupt paths. Co-Authored-By: Claude Opus 4.6 --- server/chat.ts | 9 ++++++--- server/index.ts | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/server/chat.ts b/server/chat.ts index e2c0b8c..4c0ee4f 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -281,6 +281,7 @@ export async function startChat( mode?: MitzoMode; worktree?: boolean; images?: Array<{ data: string; mediaType: string }>; + contextBlocks?: string[]; }, ) { const abortController = new AbortController(); @@ -288,7 +289,7 @@ export async function startChat( const baseCwd = options.cwd || BASE_REPO; const { cwd, worktreePath } = resolveWorktree(ws, baseCwd, options); - const fullPrompt = assemblePrompt(prompt, cwd, options.images); + const fullPrompt = assemblePrompt(prompt, cwd, options.images, options.contextBlocks); const modeAllowed = getAllowedToolsForMode(mode); const mcpAllowed = buildMcpAllowedTools(); @@ -426,10 +427,11 @@ export function sendToChat( clientId: string, prompt: string, images?: Array<{ data: string; mediaType: string }>, + contextBlocks?: string[], ): boolean { const session = registry.get(clientId); if (!session?.inputQueue) return false; - const fullPrompt = assemblePrompt(prompt, session.cwd ?? '.', images); + const fullPrompt = assemblePrompt(prompt, session.cwd ?? '.', images, contextBlocks); if (session.sessionId) { eventStore.append(session.sessionId, 'user_message', { v: 2, @@ -449,10 +451,11 @@ export async function interruptChat( clientId: string, prompt: string, images?: Array<{ data: string; mediaType: string }>, + contextBlocks?: string[], ): Promise { const session = registry.get(clientId); if (!session?.queryInstance || !session?.inputQueue) return false; - const fullPrompt = assemblePrompt(prompt, session.cwd ?? '.', images); + const fullPrompt = assemblePrompt(prompt, session.cwd ?? '.', images, contextBlocks); if (session.sessionId) { eventStore.append(session.sessionId, 'user_message', { v: 2, diff --git a/server/index.ts b/server/index.ts index a8a4dc8..e931f84 100644 --- a/server/index.ts +++ b/server/index.ts @@ -196,7 +196,7 @@ function handleChatWs(ws: WebSocket, initialClientId: string) { // Pass rendered prompt through to normal chat flow if (isActive(clientId)) { - sendToChat(clientId, resolution.renderedPrompt, msg.images); + sendToChat(clientId, resolution.renderedPrompt, msg.images, msg.contextBlocks); } else { startChat(ws, clientId, resolution.renderedPrompt, { resume: msg.resume, @@ -206,13 +206,14 @@ function handleChatWs(ws: WebSocket, initialClientId: string) { mode: msg.mode, worktree: msg.worktree, images: msg.images, + contextBlocks: msg.contextBlocks, }); } } else { // Passthrough — plain text, no slash command clearSkillPolicy(registry, clientId); if (isActive(clientId)) { - sendToChat(clientId, msg.prompt, msg.images); + sendToChat(clientId, msg.prompt, msg.images, msg.contextBlocks); } else { startChat(ws, clientId, msg.prompt, { resume: msg.resume, @@ -222,11 +223,12 @@ function handleChatWs(ws: WebSocket, initialClientId: string) { mode: msg.mode, worktree: msg.worktree, images: msg.images, + contextBlocks: msg.contextBlocks, }); } } } else if (msg.type === 'interrupt') { - interruptChat(clientId, msg.prompt, msg.images); + interruptChat(clientId, msg.prompt, msg.images, msg.contextBlocks); } else if (msg.type === 'stop') { stopChat(clientId); } From d5a250d172db9e0c097326d57f2725684f610378 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 6 Apr 2026 21:09:56 +0100 Subject: [PATCH 5/7] feat(frontend): add contextNames to FinishedMessage and USER_SEND action Store context block names on user messages for UI display. The USER_SEND reducer action now accepts contextNames which are preserved on the FinishedMessage for rendering in the user bubble. Co-Authored-By: Claude Opus 4.6 --- frontend/src/hooks/useChatMessages.ts | 3 ++- frontend/src/types/chat.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useChatMessages.ts b/frontend/src/hooks/useChatMessages.ts index 4143bf6..051a4cd 100644 --- a/frontend/src/hooks/useChatMessages.ts +++ b/frontend/src/hooks/useChatMessages.ts @@ -61,7 +61,7 @@ export type ChatMessagesAction = // Session / UI lifecycle | { type: 'ERROR'; error: string } | { type: 'SESSION_INFO'; branch: string; isWorktree: boolean } - | { type: 'USER_SEND'; text: string; images?: string[] } + | { type: 'USER_SEND'; text: string; images?: string[]; contextNames?: string[] } | { type: 'SET_RUNNING'; running: boolean } | { type: 'CONNECTION_LOST' } | { type: 'PERMISSION_REQUEST'; payload: PermissionRequest } @@ -380,6 +380,7 @@ export function chatMessagesReducer( role: 'user', blocks: [], images: action.images, + contextNames: action.contextNames, // Store text in a synthetic text block for rendering convenience. ...(action.text ? { diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index cec64ce..1cf7085 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -48,6 +48,7 @@ export interface FinishedMessage { role: 'user' | 'assistant'; blocks: FinishedBlock[]; images?: string[]; + contextNames?: string[]; } // --- Legacy flat Message type (used for restore/session history only) --- From 82afc3b3474b22b8f16c81605867d36ae5dd76a1 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 6 Apr 2026 21:12:57 +0100 Subject: [PATCH 6/7] feat(frontend): context block picker, pills, and user bubble display - ContextPicker: multi-select dropdown triggered by @ button, fetches available blocks from /api/config, shows name + file size - ChatInput: @ button in command strip, context pills row above textarea, contextBlocks passed through onSend/onInterrupt and cleared after send - UserBubble: compact "@ Name1, Name2" line when context was attached - ChatView: wires contextBlocks into WS payload and USER_SEND dispatch Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/ChatInput.tsx | 44 +++++++- frontend/src/components/ContextPicker.tsx | 98 ++++++++++++++++++ frontend/src/components/MessageBubble.tsx | 6 +- frontend/src/pages/ChatView.tsx | 33 ++++-- frontend/src/styles/global.css | 117 ++++++++++++++++++++++ 5 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/ContextPicker.tsx diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/ChatInput.tsx index ec3459c..001a3b8 100644 --- a/frontend/src/components/ChatInput.tsx +++ b/frontend/src/components/ChatInput.tsx @@ -11,13 +11,14 @@ import { resizeImage } from '../lib/resizeImage'; import { extractImageFiles } from '../lib/paste-images'; import { MAX_IMAGE_ATTACHMENTS } from '../lib/constants'; import { SlashPicker } from './SlashPicker'; +import { ContextPicker } from './ContextPicker'; import { MicButton } from './MicButton'; import type { UseVoiceReturn } from '../hooks/useVoice'; interface Props { - onSend: (text: string, images?: ImageAttachment[]) => boolean; + onSend: (text: string, images?: ImageAttachment[], contextBlocks?: string[]) => boolean; onStop: () => void; - onInterrupt?: (text: string, images?: ImageAttachment[]) => void; + onInterrupt?: (text: string, images?: ImageAttachment[], contextBlocks?: string[]) => void; running: boolean; initialText?: string; cwd?: string; @@ -40,6 +41,8 @@ export function ChatInput({ const [text, setText] = useState(initialText || ''); const [images, setImages] = useState([]); const [showSlashPicker, setShowSlashPicker] = useState(false); + const [showContextPicker, setShowContextPicker] = useState(false); + const [contextBlocks, setContextBlocks] = useState([]); const textareaRef = useRef(null); const fileInputRef = useRef(null); const initialApplied = useRef(false); @@ -93,10 +96,12 @@ export function ChatInput({ const sent = onSend( trimmed || 'What do you see in this image?', images.length > 0 ? images : undefined, + contextBlocks.length > 0 ? contextBlocks : undefined, ); if (sent) { setText(''); setImages([]); + setContextBlocks([]); requestAnimationFrame(() => { autoResize(); textareaRef.current?.focus(); @@ -179,9 +184,11 @@ export function ChatInput({ onInterrupt( trimmed || 'What do you see in this image?', images.length > 0 ? images : undefined, + contextBlocks.length > 0 ? contextBlocks : undefined, ); setText(''); setImages([]); + setContextBlocks([]); requestAnimationFrame(() => { autoResize(); textareaRef.current?.focus(); @@ -198,6 +205,17 @@ export function ChatInput({ cwd={cwd} /> )} + {showContextPicker && ( + + setContextBlocks((prev) => + prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], + ) + } + onClose={() => setShowContextPicker(false)} + /> + )} {images.length > 0 && (
{images.map((img, i) => ( @@ -210,6 +228,21 @@ export function ChatInput({ ))}
)} + {contextBlocks.length > 0 && ( +
+ {contextBlocks.map((name) => ( + + {name} + + + ))} +
+ )} {voice?.recording && voice.partialTranscript && (
{voice.partialTranscript}
)} @@ -233,6 +266,13 @@ export function ChatInput({ > + + void; + /** Called when the picker should close */ + onClose: () => void; +} + +function formatSize(bytes: number): string { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + return `${(kb / 1024).toFixed(1)} MB`; +} + +export function ContextPicker({ selected, onToggle, onClose }: Props) { + const [blocks, setBlocks] = useState([]); + const [loaded, setLoaded] = useState(false); + const pickerRef = useRef(null); + + // Fetch available context blocks from /api/config + useEffect(() => { + fetch('/api/config', { credentials: 'include' }) + .then((r) => r.json()) + .then((data: { contextBlocks?: Record }) => { + const entries: ContextBlockEntry[] = []; + if (data.contextBlocks) { + for (const [name, info] of Object.entries(data.contextBlocks)) { + entries.push({ name, path: info.path, sizeBytes: info.sizeBytes }); + } + } + setBlocks(entries); + setLoaded(true); + }) + .catch(() => { + setLoaded(true); + }); + }, []); + + // Close on click outside + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + onClose(); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose]); + + const handleToggle = useCallback( + (name: string) => { + onToggle(name); + }, + [onToggle], + ); + + if (!loaded) return null; + + if (blocks.length === 0) { + return ( +
+
No context blocks configured
+
+ ); + } + + return ( +
+
+ {blocks.map((block) => { + const isSelected = selected.includes(block.name); + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index 712e707..d663263 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -5,11 +5,15 @@ import type { FinishedMessage } from '../types/chat'; interface UserBubbleProps { text?: string; images?: string[]; + contextNames?: string[]; } -export function UserBubble({ text, images }: UserBubbleProps) { +export function UserBubble({ text, images, contextNames }: UserBubbleProps) { return (
+ {contextNames && contextNames.length > 0 && ( +
@ {contextNames.join(', ')}
+ )} {images && images.length > 0 && (
{images.map((src, i) => ( diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 469b37c..f27fd9c 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -154,7 +154,11 @@ export function ChatView() { return payload; } - function sendMessage(text: string, images?: ImageAttachment[]): boolean { + function sendMessage( + text: string, + images?: ImageAttachment[], + contextBlocks?: string[], + ): boolean { if (!wsIsOpen(poolKey)) { dispatch({ type: 'CONNECTION_LOST' }); return false; @@ -164,27 +168,37 @@ export function ChatView() { voice.stopSpeaking(); const payload = buildSendPayload(text, images); + if (contextBlocks?.length) payload.contextBlocks = contextBlocks; const previews = images?.map((img) => img.preview); if (msgState.running) { // Server queues it natively — no client-side stop+re-send needed. wsSend(poolKey, payload); - dispatch({ type: 'USER_SEND', text, images: previews }); + dispatch({ type: 'USER_SEND', text, images: previews, contextNames: contextBlocks }); forceScrollToBottom(); } else { wsSetRunning(poolKey, true); wsSend(poolKey, payload); - dispatch({ type: 'USER_SEND', text, images: previews }); + dispatch({ type: 'USER_SEND', text, images: previews, contextNames: contextBlocks }); forceScrollToBottom(); } return true; } - function interruptMessage(text: string, images?: ImageAttachment[]): void { + function interruptMessage( + text: string, + images?: ImageAttachment[], + contextBlocks?: string[], + ): void { if (!wsIsOpen(poolKey) || !msgState.running) return; const imagePayload = images?.map((img) => ({ data: img.data, mediaType: img.mediaType })); - wsSend(poolKey, { type: 'interrupt', prompt: text, images: imagePayload }); + wsSend(poolKey, { + type: 'interrupt', + prompt: text, + images: imagePayload, + ...(contextBlocks?.length ? { contextBlocks } : {}), + }); } const handleStop = useCallback(() => { @@ -285,7 +299,14 @@ export function ChatView() { {groupedMessages.map(({ msg, grouped }) => { if (msg.role === 'user') { const textBlock = msg.blocks.find((b) => b.blockType === 'text'); - return ; + return ( + + ); } // Assistant turn — render grouped blocks diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f0018bb..a3b1cf9 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2041,6 +2041,116 @@ textarea:focus { text-align: center; } +/* Context picker (multi-select, triggered by @ button) */ + +.context-picker { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + max-height: 240px; + overflow-y: auto; + margin-bottom: 0.25rem; + -webkit-overflow-scrolling: touch; +} + +.context-picker-list { + display: flex; + flex-direction: column; +} + +.context-picker-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + text-align: left; + font-size: 0.85rem; + color: var(--text); + cursor: pointer; +} + +.context-picker-item:last-child { + border-bottom: none; +} + +.context-picker-item:hover { + background: var(--hover); +} + +.context-picker-item--selected { + background: rgba(108, 99, 255, 0.08); +} + +.context-picker-check { + flex-shrink: 0; + width: 1rem; + text-align: center; + color: var(--accent); + font-weight: 700; +} + +.context-picker-name { + font-weight: 600; + white-space: nowrap; +} + +.context-picker-size { + color: var(--text-dim); + font-size: 0.75rem; + margin-left: auto; +} + +.context-picker-empty { + padding: 0.75rem; + color: var(--text-dim); + font-size: 0.85rem; + text-align: center; +} + +/* Context pills (selected blocks shown above textarea) */ + +.chat-input-context-pills { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.25rem 0.5rem; +} + +.chat-input-context-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + color: var(--accent); + background: rgba(108, 99, 255, 0.1); + border: 1px solid var(--accent); + border-radius: 4px; + padding: 0.1rem 0.35rem; +} + +.chat-input-context-pill-remove { + background: none; + border: none; + color: var(--accent); + font-size: 0.8rem; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.chat-input-btn--context { + font-size: 0.85rem; + font-weight: 600; +} + +.chat-input-btn--active { + color: var(--accent); +} + /* Image preview strip above input */ .chat-input-previews { @@ -2087,6 +2197,13 @@ textarea:focus { /* Images in message bubbles */ +.msg-bubble-context { + font-size: 0.7rem; + color: var(--accent); + margin-bottom: 0.3rem; + opacity: 0.8; +} + .msg-bubble-images { display: flex; flex-wrap: wrap; From 13e5718383b86290aee1330e5d99173913dab5c4 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 6 Apr 2026 21:35:44 +0100 Subject: [PATCH 7/7] fix(context-blocks): sanitize XML attrs, add size limit, normalize naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Escape XML-unsafe characters in context block name/source attributes - Truncate context block files exceeding 100 KB with warning - Remove no-op handleToggle useCallback wrapper in ContextPicker - Dispatch USER_SEND on interrupt so context annotation shows in bubble - Rename contextNames → contextBlocks across all frontend files - Add tests for XML-unsafe names and size limit truncation Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/ContextPicker.tsx | 11 ++---- frontend/src/components/MessageBubble.tsx | 8 ++--- frontend/src/hooks/useChatMessages.ts | 4 +-- frontend/src/pages/ChatView.tsx | 9 +++-- frontend/src/types/chat.ts | 2 +- server/__tests__/assemble-prompt.test.ts | 43 +++++++++++++++++++++++ server/chat.ts | 26 +++++++++++++- 7 files changed, 83 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ContextPicker.tsx b/frontend/src/components/ContextPicker.tsx index e5b7e10..f26bb36 100644 --- a/frontend/src/components/ContextPicker.tsx +++ b/frontend/src/components/ContextPicker.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef } from 'react'; export interface ContextBlockEntry { name: string; @@ -58,13 +58,6 @@ export function ContextPicker({ selected, onToggle, onClose }: Props) { return () => document.removeEventListener('mousedown', handleClickOutside); }, [onClose]); - const handleToggle = useCallback( - (name: string) => { - onToggle(name); - }, - [onToggle], - ); - if (!loaded) return null; if (blocks.length === 0) { @@ -84,7 +77,7 @@ export function ContextPicker({ selected, onToggle, onClose }: Props) {