diff --git a/frontend/src/components/BootContextPill.tsx b/frontend/src/components/BootContextPill.tsx new file mode 100644 index 00000000..21acee65 --- /dev/null +++ b/frontend/src/components/BootContextPill.tsx @@ -0,0 +1,43 @@ +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 dotClass = isContexgin ? 'boot-context-pill-dot--ok' : 'boot-context-pill-dot--warn'; + 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, idx) => ( +
+ {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..dafba8cc 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,9 +152,10 @@ export function ChatArea({ onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > + {bootContext && } {sessionContext && } - {messages.length === 0 && !current && !running && !sessionContext && ( + {messages.length === 0 && !current && !running && !sessionContext && !bootContext && (

Send a message to start

)} 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/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 d20f066a..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 */ @@ -2293,6 +2295,89 @@ 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-dot--ok { + background: var(--status-ok); +} + +.boot-context-pill-dot--warn { + background: var(--status-warn); +} + +.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: var(--status-warn); +} + /* ===== Tool Group ===== */ .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..dbceb72a 100644 --- a/packages/client/__tests__/protocol-parser.test.ts +++ b/packages/client/__tests__/protocol-parser.test.ts @@ -702,3 +702,120 @@ 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: [], + }, + }); + }); + + 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/index.ts b/packages/client/src/index.ts index 55261765..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 } 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..067a538e 100644 --- a/packages/client/src/protocol-parser.ts +++ b/packages/client/src/protocol-parser.ts @@ -194,6 +194,23 @@ export function parseServerMessage( }); break; + 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; + // 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 }, + }); + 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..148ad70c 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -728,7 +728,84 @@ 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 + (async () => { + // Step 1: dynamically import contexgin (optional dependency) + let compileModule: { + compile: (opts: { workspaceRoot: string; tokenBudget: number }) => Promise; + }; + try { + 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: sourcePaths.length, + tokenCount: bootTokens, + trimmedCount: trimmed.length, + sources: sourcePaths, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log.warn('boot context compilation failed', { error: msg }); + send(transport, { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + trimmedCount: 0, + sources: [], + }); + } + })(); capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); try {