From e1596c360d86648ceaa40aaa5eb00c96cb9d1066 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 4 May 2026 23:23:06 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20boot=20context=20pill=20?= =?UTF-8?q?=E2=80=94=20show=20ContexGin=20compilation=20metadata=20at=20se?= =?UTF-8?q?ssion=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads ContexGin compile result from server through protocol to a tappable pill at the top of the chat. Shows source count, token count, and compilation engine (green dot for ContexGin, amber for local fallback). Expands to list source files and trimmed section count. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/BootContextPill.tsx | 48 +++++++++++++ frontend/src/components/ChatArea.tsx | 6 ++ frontend/src/pages/ChatView.tsx | 2 + frontend/src/styles/global.css | 75 +++++++++++++++++++++ packages/client/src/index.ts | 2 +- packages/client/src/protocol-parser.ts | 13 ++++ packages/client/src/slices/messages.ts | 14 ++++ server/chat.ts | 24 ++++++- 8 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/BootContextPill.tsx diff --git a/frontend/src/components/BootContextPill.tsx b/frontend/src/components/BootContextPill.tsx new file mode 100644 index 00000000..40cd5549 --- /dev/null +++ b/frontend/src/components/BootContextPill.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import type { BootContextMeta } from '@mitzo/client'; + +interface Props { + context: BootContextMeta; +} + +export function BootContextPill({ context }: Props) { + const [expanded, setExpanded] = useState(false); + + const isContexgin = context.source === 'contexgin'; + const dotColor = isContexgin ? '#4ade80' : '#fbbf24'; + const tokenLabel = + context.tokenCount >= 1000 + ? `${(context.tokenCount / 1000).toFixed(1)}k` + : String(context.tokenCount); + const label = `${context.sourceCount} sources \u00b7 ${tokenLabel} tokens`; + + return ( +
+ + {expanded && ( +
+ {context.sources.map((src) => ( +
+ {src} +
+ ))} + {context.trimmedCount > 0 && ( +
+ {context.trimmedCount} section{context.trimmedCount !== 1 ? 's' : ''} trimmed +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ChatArea.tsx b/frontend/src/components/ChatArea.tsx index edccfc77..cd9d5cfa 100644 --- a/frontend/src/components/ChatArea.tsx +++ b/frontend/src/components/ChatArea.tsx @@ -4,6 +4,7 @@ import { ThinkingBlock } from './ThinkingBlock'; import { ToolPill } from './ToolPill'; import { ToolGroup } from './ToolGroup'; import { ContextBlock } from './ContextBlock'; +import { BootContextPill } from './BootContextPill'; import { PermissionBanner } from './PermissionBanner'; import { ProgressWidget } from './ProgressWidget'; import { groupBlocks } from '../lib/groupMessages'; @@ -15,6 +16,7 @@ import type { PermissionRequest, } from '../types/chat'; import type { ProgressBlock } from '@mitzo/protocol'; +import type { BootContextMeta } from '@mitzo/client'; import type { UseVoiceReturn } from '../hooks/useVoice'; export type ChatAreaVoice = Pick< @@ -36,6 +38,8 @@ export interface ChatAreaProps { scrollRef?: React.RefObject; /** Boot context for sessions started from inbox/todo items */ sessionContext?: string | null; + /** Boot context compilation metadata from ContexGin */ + bootContext?: BootContextMeta | null; /** Progress blocks indexed by toolId for rendering ProgressWidget on TodoWrite blocks */ progressByToolId?: Record; /** Voice capabilities for per-block read-aloud */ @@ -50,6 +54,7 @@ export function ChatArea({ onPermissionRespond, scrollRef: externalScrollRef, sessionContext, + bootContext, progressByToolId, voice, }: ChatAreaProps) { @@ -147,6 +152,7 @@ export function ChatArea({ onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > + {bootContext && } {sessionContext && } {messages.length === 0 && !current && !running && !sessionContext && ( diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 74fb8b86..5f42f588 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -51,6 +51,7 @@ export function ChatView() { const pendingSession = useMitzoStore((s) => s.pendingSession); const clearPendingSession = useMitzoStore((s) => s.clearPendingSession); const sessionContext = useMitzoStore((s) => s.messages.sessionContext); + const bootContext = useMitzoStore((s) => s.messages.bootContext); const progressByToolId = useProgressByToolId(); const connected = connection.status === 'connected'; @@ -267,6 +268,7 @@ export function ChatView() { onPermissionRespond={handlePermission} scrollRef={scrollRef} sessionContext={sessionContext} + bootContext={bootContext} progressByToolId={progressByToolId} voice={voice} /> diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index d20f066a..9f0ceaf1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2293,6 +2293,81 @@ textarea:focus { opacity: 0.7; } +/* ===== Boot Context Pill (ContexGin metadata) ===== */ + +.boot-context-pill { + align-self: flex-start; + width: 100%; + border-radius: 6px; + overflow: hidden; + margin-bottom: 0.3rem; +} + +.boot-context-pill-header { + display: flex; + align-items: center; + gap: 0.4rem; + touch-action: manipulation; + padding: 0.25rem 0.6rem; + width: 100%; + cursor: pointer; + background: transparent; + border: none; + -webkit-tap-highlight-color: transparent; + font-size: var(--text-xxs); + font-family: inherit; + color: var(--text-dim); + min-width: 0; +} + +.boot-context-pill-header:active { + background: rgba(255, 255, 255, 0.03); +} + +.boot-context-pill-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.boot-context-pill-label { + font-size: var(--text-xxs); +} + +.boot-context-pill-engine { + font-size: var(--text-xxs); + opacity: 0.5; +} + +.boot-context-pill-chevron { + margin-left: auto; + font-size: 0.6rem; + opacity: 0.5; +} + +.boot-context-pill-content { + padding: 0.3rem 0.6rem 0.3rem 1.2rem; + font-family: var(--code-font); + font-size: var(--text-xxs); + line-height: 1.5; + color: var(--text-dim); + opacity: 0.7; +} + +.boot-context-pill-source { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.boot-context-pill-trimmed { + margin-top: 0.2rem; + font-style: italic; + color: #fbbf24; +} + /* ===== Tool Group ===== */ .tool-group { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 55261765..ec9a630b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,7 +17,7 @@ export type { } from './store.js'; // Slices — state shapes and types -export type { MessagesState, MessagesAction, ActiveWorktree } from './slices/messages.js'; +export type { MessagesState, MessagesAction, ActiveWorktree, BootContextMeta } from './slices/messages.js'; export { messagesReducer, INITIAL_MESSAGES_STATE } from './slices/messages.js'; export type { SessionsState } from './slices/sessions.js'; export type { ConnectionState, ConnectionStatus } from './slices/connection.js'; diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index 09ea1acc..ca2eaa76 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -194,6 +194,19 @@ export function parseServerMessage( }); break; + case 'boot_context': + result.messagesActions.push({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + source: msg.source as 'contexgin' | 'local-fallback', + sourceCount: msg.sourceCount as number, + tokenCount: msg.tokenCount as number, + trimmedCount: msg.trimmedCount as number, + sources: msg.sources as string[], + }, + }); + break; + case 'worktree_opened': result.messagesActions.push({ type: 'WORKTREE_OPENED', diff --git a/packages/client/src/slices/messages.ts b/packages/client/src/slices/messages.ts index ee17be3e..ad67dd25 100644 --- a/packages/client/src/slices/messages.ts +++ b/packages/client/src/slices/messages.ts @@ -24,6 +24,14 @@ export interface ActiveWorktree { path: string; } +export interface BootContextMeta { + source: 'contexgin' | 'local-fallback'; + sourceCount: number; + tokenCount: number; + trimmedCount: number; + sources: string[]; +} + export interface MessagesState { messages: FinishedMessage[]; current: StreamingMessage | null; @@ -34,6 +42,7 @@ export interface MessagesState { wtId: string | null; activeWorktrees: ActiveWorktree[]; sessionContext: string | null; + bootContext: BootContextMeta | null; } export const INITIAL_MESSAGES_STATE: MessagesState = { @@ -46,6 +55,7 @@ export const INITIAL_MESSAGES_STATE: MessagesState = { wtId: null, activeWorktrees: [], sessionContext: null, + bootContext: null, }; // ─── Actions ───────────────────────────────────────────────────────────────── @@ -137,6 +147,7 @@ export type MessagesAction = | { type: 'WORKTREE_OPENED'; repoName: string; path: string } | { type: 'NATIVE_COMMAND_RESULT'; command: string; content: string } | { type: 'SET_SESSION_CONTEXT'; context: string } + | { type: 'SET_BOOT_CONTEXT'; bootContext: BootContextMeta } | { type: 'CLEAR' }; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -411,6 +422,9 @@ export function messagesReducer(state: MessagesState, action: MessagesAction): M case 'SET_SESSION_CONTEXT': return { ...state, sessionContext: action.context }; + case 'SET_BOOT_CONTEXT': + return { ...state, bootContext: action.bootContext }; + case 'CLEAR': return { ...INITIAL_MESSAGES_STATE }; diff --git a/server/chat.ts b/server/chat.ts index e07093cb..12370427 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -728,7 +728,29 @@ async function _startChatInner( buildWorktreeSystemPrompt(repoWorktrees) + buildTaskPromptForSession(clientId); - // Fire-and-forget: capture prompt comparison for the experiments spoke + // Fire-and-forget: emit boot context metadata to client + capture prompt comparison + import('contexgin') + .then(({ compile }) => compile({ workspaceRoot: cwd, tokenBudget: 8000 })) + .then((compiled) => { + send(transport, { + type: 'boot_context', + source: 'contexgin', + sourceCount: compiled.sources.length, + tokenCount: compiled.bootTokens, + trimmedCount: compiled.trimmed?.length ?? 0, + sources: compiled.sources.map((s: { relativePath: string }) => s.relativePath), + }); + }) + .catch(() => { + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + }); capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); try { From 6fa5ab6aa8dd53bf4a3e0a4e3d05087893f61637 Mon Sep 17 00:00:00 2001 From: dimakis Date: Mon, 4 May 2026 23:36:43 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix(ui):=20address=20PR=20#313=20review=20f?= =?UTF-8?q?indings=20=E2=80=94=20desktop=20view,=20validation,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire bootContext + sessionContext to DesktopChatView (was missing) - Server: async/await with try/catch, validate compiled shape, log errors - Protocol parser: runtime validation instead of bare `as` casts - CSS: define --status-ok/--status-warn vars, use class modifiers - Add 6 tests: SET_BOOT_CONTEXT reducer (3) + boot_context parser (3) Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/BootContextPill.tsx | 4 +- frontend/src/pages/DesktopChatView.tsx | 4 ++ frontend/src/styles/global.css | 12 +++- .../client/__tests__/messages-slice.test.ts | 49 +++++++++++++ .../client/__tests__/protocol-parser.test.ts | 71 +++++++++++++++++++ packages/client/src/protocol-parser.ts | 16 ++--- server/chat.ts | 25 ++++--- 7 files changed, 160 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/BootContextPill.tsx b/frontend/src/components/BootContextPill.tsx index 40cd5549..980e6a4e 100644 --- a/frontend/src/components/BootContextPill.tsx +++ b/frontend/src/components/BootContextPill.tsx @@ -9,7 +9,7 @@ export function BootContextPill({ context }: Props) { const [expanded, setExpanded] = useState(false); const isContexgin = context.source === 'contexgin'; - const dotColor = isContexgin ? '#4ade80' : '#fbbf24'; + const dotClass = isContexgin ? 'boot-context-pill-dot--ok' : 'boot-context-pill-dot--warn'; const tokenLabel = context.tokenCount >= 1000 ? `${(context.tokenCount / 1000).toFixed(1)}k` @@ -22,7 +22,7 @@ export function BootContextPill({ context }: Props) { className="boot-context-pill-header" onClick={() => setExpanded((e) => !e)} > - + {label} {isContexgin ? 'ContexGin' : 'Fallback'} diff --git a/frontend/src/pages/DesktopChatView.tsx b/frontend/src/pages/DesktopChatView.tsx index cd5c734b..94a81355 100644 --- a/frontend/src/pages/DesktopChatView.tsx +++ b/frontend/src/pages/DesktopChatView.tsx @@ -38,6 +38,8 @@ export function DesktopChatView() { const storeSetModel = useMitzoStore((s) => s.setModel); const storeDispatchMessages = useMitzoStore((s) => s.dispatchMessages); const storeFetchSessionMeta = useMitzoStore((s) => s.fetchSessionMeta); + const sessionContext = useMitzoStore((s) => s.messages.sessionContext); + const bootContext = useMitzoStore((s) => s.messages.bootContext); const progressByToolId = useProgressByToolId(); const connected = connection.status === 'connected'; @@ -236,6 +238,8 @@ export function DesktopChatView() { permission={messages.permission} onPermissionRespond={handlePermission} scrollRef={scrollRef} + sessionContext={sessionContext} + bootContext={bootContext} progressByToolId={progressByToolId} voice={voice} /> diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9f0ceaf1..2e87ce08 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -23,6 +23,8 @@ --hover: rgba(108, 99, 255, 0.1); --active: rgba(108, 99, 255, 0.2); --warning: #ff9800; + --status-ok: #4ade80; + --status-warn: #fbbf24; --shadow: rgba(0, 0, 0, 0.3); /* Task state colors */ @@ -2332,6 +2334,14 @@ textarea:focus { flex-shrink: 0; } +.boot-context-pill-dot--ok { + background: var(--status-ok); +} + +.boot-context-pill-dot--warn { + background: var(--status-warn); +} + .boot-context-pill-label { font-size: var(--text-xxs); } @@ -2365,7 +2375,7 @@ textarea:focus { .boot-context-pill-trimmed { margin-top: 0.2rem; font-style: italic; - color: #fbbf24; + color: var(--status-warn); } /* ===== Tool Group ===== */ diff --git a/packages/client/__tests__/messages-slice.test.ts b/packages/client/__tests__/messages-slice.test.ts index 43503f12..e0607946 100644 --- a/packages/client/__tests__/messages-slice.test.ts +++ b/packages/client/__tests__/messages-slice.test.ts @@ -1228,3 +1228,52 @@ describe('NATIVE_COMMAND_RESULT', () => { expect(msg.blocks[0].content).toContain('Available skills'); }); }); + +// ─── SET_BOOT_CONTEXT ──────────────────────────────────────────────────────── + +describe('SET_BOOT_CONTEXT', () => { + it('sets bootContext from null', () => { + const meta = { + source: 'contexgin' as const, + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }; + const state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: meta }); + expect(state.bootContext).toEqual(meta); + }); + + it('overwrites existing bootContext', () => { + const first = { + source: 'local-fallback' as const, + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [] as string[], + }; + const second = { + source: 'contexgin' as const, + sourceCount: 3, + tokenCount: 1500, + trimmedCount: 0, + sources: ['a.md'], + }; + let state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: first }); + state = messagesReducer(state, { type: 'SET_BOOT_CONTEXT', bootContext: second }); + expect(state.bootContext).toEqual(second); + }); + + it('is cleared by CLEAR', () => { + const meta = { + source: 'contexgin' as const, + sourceCount: 2, + tokenCount: 1000, + trimmedCount: 0, + sources: ['a.md'], + }; + let state = messagesReducer(INITIAL, { type: 'SET_BOOT_CONTEXT', bootContext: meta }); + state = messagesReducer(state, { type: 'CLEAR' }); + expect(state.bootContext).toBeNull(); + }); +}); diff --git a/packages/client/__tests__/protocol-parser.test.ts b/packages/client/__tests__/protocol-parser.test.ts index 03ef7f30..90437918 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -702,3 +702,74 @@ describe('error with No conversation found', () => { expect(r.messagesActions).toContainEqual(expect.objectContaining({ type: 'ERROR' })); }); }); + +// ─── boot_context ───────────────────────────────────────────────────────────── + +describe('boot_context', () => { + it('maps boot_context to SET_BOOT_CONTEXT with validated fields', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions).toEqual([ + { + type: 'SET_BOOT_CONTEXT', + bootContext: { + source: 'contexgin', + sourceCount: 5, + tokenCount: 3200, + trimmedCount: 1, + sources: ['CLAUDE.md', 'CONSTITUTION.md'], + }, + }, + ]); + }); + + it('normalizes unknown source to local-fallback', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'unknown-engine', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions[0]).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { source: 'local-fallback' }, + }); + }); + + it('defaults missing numeric fields to 0 and sources to empty array', () => { + const r = parseServerMessage( + { type: 'boot_context', source: 'contexgin' }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + expect(r.messagesActions[0]).toEqual({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + source: 'contexgin', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }, + }); + }); +}); diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index ca2eaa76..8dfb1a8c 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -194,18 +194,18 @@ export function parseServerMessage( }); break; - case 'boot_context': + case 'boot_context': { + const source = msg.source === 'contexgin' ? 'contexgin' : 'local-fallback'; + const sourceCount = typeof msg.sourceCount === 'number' ? msg.sourceCount : 0; + const tokenCount = typeof msg.tokenCount === 'number' ? msg.tokenCount : 0; + const trimmedCount = typeof msg.trimmedCount === 'number' ? msg.trimmedCount : 0; + const sources = Array.isArray(msg.sources) ? (msg.sources as string[]) : []; result.messagesActions.push({ type: 'SET_BOOT_CONTEXT', - bootContext: { - source: msg.source as 'contexgin' | 'local-fallback', - sourceCount: msg.sourceCount as number, - tokenCount: msg.tokenCount as number, - trimmedCount: msg.trimmedCount as number, - sources: msg.sources as string[], - }, + bootContext: { source, sourceCount, tokenCount, trimmedCount, sources }, }); break; + } case 'worktree_opened': result.messagesActions.push({ diff --git a/server/chat.ts b/server/chat.ts index 12370427..2b087015 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -729,19 +729,23 @@ async function _startChatInner( buildTaskPromptForSession(clientId); // Fire-and-forget: emit boot context metadata to client + capture prompt comparison - import('contexgin') - .then(({ compile }) => compile({ workspaceRoot: cwd, tokenBudget: 8000 })) - .then((compiled) => { + (async () => { + try { + const { compile } = await import('contexgin'); + const compiled = await compile({ workspaceRoot: cwd, tokenBudget: 8000 }); + const sources = Array.isArray(compiled?.sources) ? compiled.sources : []; + const trimmed = Array.isArray(compiled?.trimmed) ? compiled.trimmed : []; send(transport, { type: 'boot_context', source: 'contexgin', - sourceCount: compiled.sources.length, - tokenCount: compiled.bootTokens, - trimmedCount: compiled.trimmed?.length ?? 0, - sources: compiled.sources.map((s: { relativePath: string }) => s.relativePath), + sourceCount: sources.length, + tokenCount: typeof compiled?.bootTokens === 'number' ? compiled.bootTokens : 0, + trimmedCount: trimmed.length, + sources: sources.map((s: { relativePath: string }) => s.relativePath), }); - }) - .catch(() => { + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log.warn('boot context compilation failed, using fallback', { error: msg }); send(transport, { type: 'boot_context', source: 'local-fallback', @@ -750,7 +754,8 @@ async function _startChatInner( trimmedCount: 0, sources: [], }); - }); + } + })(); capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); try { From db965393ad1eaff14f17610070a22fc322203367 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 9 May 2026 14:41:58 +0100 Subject: [PATCH 3/4] fix(ui): address Centaur review items on boot context pill - Separate import vs compilation try/catch in server boot context IIFE - Add runtime shape validation for contexgin compile() return value - Validate individual source entries before sending over WS - Filter non-string elements from sources array in protocol parser - Fix empty-state overlap when bootContext is present - Use index-based React keys for source list items - Add tests for sources validation edge cases (non-string, non-array) Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/BootContextPill.tsx | 13 ++-- frontend/src/components/ChatArea.tsx | 2 +- .../client/__tests__/protocol-parser.test.ts | 46 +++++++++++++ packages/client/src/protocol-parser.ts | 6 +- server/chat.ts | 66 ++++++++++++++++--- 5 files changed, 114 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/BootContextPill.tsx b/frontend/src/components/BootContextPill.tsx index 980e6a4e..21acee65 100644 --- a/frontend/src/components/BootContextPill.tsx +++ b/frontend/src/components/BootContextPill.tsx @@ -18,21 +18,16 @@ export function BootContextPill({ context }: Props) { return (
- {expanded && (
- {context.sources.map((src) => ( -
+ {context.sources.map((src, idx) => ( +
{src}
))} diff --git a/frontend/src/components/ChatArea.tsx b/frontend/src/components/ChatArea.tsx index cd9d5cfa..dafba8cc 100644 --- a/frontend/src/components/ChatArea.tsx +++ b/frontend/src/components/ChatArea.tsx @@ -155,7 +155,7 @@ export function ChatArea({ {bootContext && } {sessionContext && } - {messages.length === 0 && !current && !running && !sessionContext && ( + {messages.length === 0 && !current && !running && !sessionContext && !bootContext && (

Send a message to start

)} diff --git a/packages/client/__tests__/protocol-parser.test.ts b/packages/client/__tests__/protocol-parser.test.ts index 90437918..dbceb72a 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -772,4 +772,50 @@ describe('boot_context', () => { }, }); }); + + it('filters out non-string elements from sources array', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 3, + tokenCount: 1000, + trimmedCount: 0, + sources: ['CLAUDE.md', 42, null, undefined, { relativePath: 'foo.md' }, 'README.md'], + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + const action = r.messagesActions[0]; + expect(action).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + sources: ['CLAUDE.md', 'README.md'], + }, + }); + }); + + it('handles sources as a non-array value gracefully', () => { + const r = parseServerMessage( + { + type: 'boot_context', + source: 'contexgin', + sourceCount: 1, + tokenCount: 500, + trimmedCount: 0, + sources: 'not-an-array', + }, + makeState(), + makeCallbacks(), + POOL_KEY, + ); + const action = r.messagesActions[0]; + expect(action).toMatchObject({ + type: 'SET_BOOT_CONTEXT', + bootContext: { + sources: [], + }, + }); + }); }); diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts index 8dfb1a8c..067a538e 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -199,7 +199,11 @@ export function parseServerMessage( const sourceCount = typeof msg.sourceCount === 'number' ? msg.sourceCount : 0; const tokenCount = typeof msg.tokenCount === 'number' ? msg.tokenCount : 0; const trimmedCount = typeof msg.trimmedCount === 'number' ? msg.trimmedCount : 0; - const sources = Array.isArray(msg.sources) ? (msg.sources as string[]) : []; + // Validate each element is a string — filter out non-string entries + const rawSources = Array.isArray(msg.sources) ? msg.sources : []; + const sources: string[] = rawSources.filter( + (s: unknown): s is string => typeof s === 'string', + ); result.messagesActions.push({ type: 'SET_BOOT_CONTEXT', bootContext: { source, sourceCount, tokenCount, trimmedCount, sources }, diff --git a/server/chat.ts b/server/chat.ts index 2b087015..148ad70c 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -730,22 +730,72 @@ async function _startChatInner( // Fire-and-forget: emit boot context metadata to client + capture prompt comparison (async () => { + // Step 1: dynamically import contexgin (optional dependency) + let compileModule: { + compile: (opts: { workspaceRoot: string; tokenBudget: number }) => Promise; + }; try { - const { compile } = await import('contexgin'); - const compiled = await compile({ workspaceRoot: cwd, tokenBudget: 8000 }); - const sources = Array.isArray(compiled?.sources) ? compiled.sources : []; - const trimmed = Array.isArray(compiled?.trimmed) ? compiled.trimmed : []; + compileModule = await import('contexgin'); + } catch (importErr: unknown) { + const msg = importErr instanceof Error ? importErr.message : String(importErr); + log.info('contexgin not available, using fallback', { error: msg }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + return; + } + + // Step 2: compile — runtime errors propagate (not swallowed as import failure) + try { + const compiled = await compileModule.compile({ workspaceRoot: cwd, tokenBudget: 8000 }); + + // Validate the compiled object shape + if (!compiled || typeof compiled !== 'object') { + log.warn('contexgin compile() returned unexpected shape', { compiled }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + return; + } + + const obj = compiled as Record; + const sources = Array.isArray(obj.sources) ? obj.sources : []; + const trimmed = Array.isArray(obj.trimmed) ? obj.trimmed : []; + const bootTokens = typeof obj.bootTokens === 'number' ? obj.bootTokens : 0; + + // Validate each source entry has a relativePath string + const sourcePaths: string[] = []; + for (const s of sources) { + if ( + s && + typeof s === 'object' && + typeof (s as Record).relativePath === 'string' + ) { + sourcePaths.push((s as Record).relativePath as string); + } + } + send(transport, { type: 'boot_context', source: 'contexgin', - sourceCount: sources.length, - tokenCount: typeof compiled?.bootTokens === 'number' ? compiled.bootTokens : 0, + sourceCount: sourcePaths.length, + tokenCount: bootTokens, trimmedCount: trimmed.length, - sources: sources.map((s: { relativePath: string }) => s.relativePath), + sources: sourcePaths, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - log.warn('boot context compilation failed, using fallback', { error: msg }); + log.warn('boot context compilation failed', { error: msg }); send(transport, { type: 'boot_context', source: 'local-fallback', From c61afdbb944960b54692d9b0e2cc0943c0b5032d Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 9 May 2026 15:25:10 +0100 Subject: [PATCH 4/4] style: fix prettier formatting in client index Co-Authored-By: Claude Opus 4.6 --- packages/client/src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ec9a630b..5888b322 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,7 +17,12 @@ export type { } from './store.js'; // Slices — state shapes and types -export type { MessagesState, MessagesAction, ActiveWorktree, BootContextMeta } from './slices/messages.js'; +export type { + MessagesState, + MessagesAction, + ActiveWorktree, + BootContextMeta, +} from './slices/messages.js'; export { messagesReducer, INITIAL_MESSAGES_STATE } from './slices/messages.js'; export type { SessionsState } from './slices/sessions.js'; export type { ConnectionState, ConnectionStatus } from './slices/connection.js';