diff --git a/frontend/src/components/ChatArea.tsx b/frontend/src/components/ChatArea.tsx index 5fa5c496..802f31dd 100644 --- a/frontend/src/components/ChatArea.tsx +++ b/frontend/src/components/ChatArea.tsx @@ -5,6 +5,7 @@ import { ToolPill } from './ToolPill'; import { ToolGroup } from './ToolGroup'; import { PermissionBanner } from './PermissionBanner'; import { ProgressWidget } from './ProgressWidget'; +import { SessionBanner } from './SessionBanner'; import { groupBlocks } from '../lib/groupMessages'; import { SCROLL_NEAR_BOTTOM_PX } from '../lib/constants'; import type { @@ -13,6 +14,7 @@ import type { StreamingMessage, PermissionRequest, } from '../types/chat'; +import type { BootContextMeta } from '@mitzo/client'; import type { ProgressBlock } from '@mitzo/protocol'; import type { UseVoiceReturn } from '../hooks/useVoice'; @@ -37,6 +39,10 @@ export interface ChatAreaProps { progressByToolId?: Record; /** Voice capabilities for per-block read-aloud */ voice?: ChatAreaVoice; + /** Boot context metadata for sticky banner */ + bootContext?: BootContextMeta | null; + /** Session context string for sticky banner */ + sessionContext?: string | null; } export function ChatArea({ @@ -48,6 +54,8 @@ export function ChatArea({ scrollRef: externalScrollRef, progressByToolId, voice, + bootContext, + sessionContext, }: ChatAreaProps) { const internalScrollRef = useRef(null); const scrollRef = externalScrollRef ?? internalScrollRef; @@ -143,6 +151,8 @@ export function ChatArea({ onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > + + {messages.length === 0 && !current && !running && (

Send a message to start

)} diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 0f910b2b..fbbc9702 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { ChatArea } from '../components/ChatArea'; import { ChatInput } from '../components/ChatInput'; -import { SessionBanner } from '../components/SessionBanner'; import { VoiceSettings } from '../components/VoiceSettings'; import { MitzoLogo } from '../components/MitzoLogo'; import { useMessages, useConnection, useTokens, useMitzoStore } from '@mitzo/client/hooks'; @@ -272,8 +271,6 @@ export function ChatView() { )} - - - { expect(result.fullMarkdown).toBe('json fallback'); }); + it('returns fullMarkdown suitable for system prompt append', async () => { + const markdown = '# Identity\nYou are a helpful assistant.\n\n# Preferences\nBe concise.'; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + boot: { + content: markdown, + tokens: 200, + tokenBudget: 8000, + sources: ['profile.md'], + }, + }), + }); + + const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL); + + expect(result.fullMarkdown).toBe(markdown); + // Verify the append pattern matches what chat.ts does: + // `\n\n# Boot Context\n${fullMarkdown}` + const append = result.fullMarkdown ? `\n\n# Boot Context\n${result.fullMarkdown}` : ''; + expect(append).toContain('# Boot Context'); + expect(append).toContain('# Identity'); + expect(append).toContain('Be concise.'); + }); + + it('returns empty string for append when fullMarkdown is missing', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + boot: { tokens: 0, sources: [] }, + }), + }); + + const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL); + + expect(result.fullMarkdown).toBeUndefined(); + const append = result.fullMarkdown ? `\n\n# Boot Context\n${result.fullMarkdown}` : ''; + expect(append).toBe(''); + }); + it('uses default URL from env when not provided', async () => { const origUrl = process.env.CONTEXGIN_URL; process.env.CONTEXGIN_URL = 'http://test-host:9999'; diff --git a/server/chat.ts b/server/chat.ts index b4856f57..118ec205 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -900,6 +900,26 @@ async function _startChatInner( // Load project hooks from .claude/settings.json (e.g. SessionStart boot context) const hooks = loadProjectHooks(cwd); + // Fetch boot context BEFORE building system prompt so it's part of the + // system prompt append and survives SDK context compaction. + const bootContextMsg = await fetchBootContext(agentName); + const bootContextAppend = bootContextMsg.fullMarkdown + ? `\n\n# Boot Context\n${bootContextMsg.fullMarkdown}` + : ''; + + // Send boot context to UI for display (separate from system prompt injection). + // stateSessionId is the worktree-level session ID, already computed above. + send(transport, { ...bootContextMsg, ...(stateSessionId ? { sessionId: stateSessionId } : {}) }); + // Cache in ManagedSession for replay on reconnect/switch + session.bootContext = bootContextMsg as unknown as Record; + // Persist to EventStore for cold-path recovery + if (stateSessionId) { + eventStore.upsertSession({ + sessionId: stateSessionId, + bootContext: JSON.stringify(bootContextMsg), + }); + } + // Build the system prompt append string (used by both query and comparison) const systemPromptAppend = 'This is Mitzo, a mobile chat interface. The user is on their phone.\n' + @@ -908,28 +928,9 @@ async function _startChatInner( '- Keep responses concise — small screen.\n' + '- Read CLAUDE.md and .cursor/rules/ for project context before doing substantive work.' + buildWorktreeSystemPrompt(repoWorktrees) + - buildTaskPromptForSession(clientId); - - // Fire-and-forget: fetch boot context from running ContexGin server - fetchBootContext(agentName) - .then((msg) => { - // Include sessionId for consistency with reconnect/switch replay paths - const sid = options.resume ?? registry.get(clientId)?.sessionId; - send(transport, { ...msg, ...(sid ? { sessionId: sid } : {}) }); - // Cache in ManagedSession for replay on reconnect/switch - const s = registry.get(clientId); - if (s) s.bootContext = msg as unknown as Record; - // Persist to EventStore for cold-path recovery (resumed sessions - // may not run the query loop long enough for it to upsert). - if (sid) { - eventStore.upsertSession({ sessionId: sid, bootContext: JSON.stringify(msg) }); - } - }) - .catch((err: unknown) => { - log.warn('boot context fetch unexpected error', { - error: err instanceof Error ? err.message : String(err), - }); - }); + buildTaskPromptForSession(clientId) + + bootContextAppend; + capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); // Resolve SDK session UUID for resume — worktree IDs are not valid SDK session IDs