Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions frontend/src/components/BootContextPill.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="boot-context-pill">
<button className="boot-context-pill-header" onClick={() => setExpanded((e) => !e)}>
<span className={`boot-context-pill-dot ${dotClass}`} />
<span className="boot-context-pill-label">{label}</span>
<span className="boot-context-pill-engine">{isContexgin ? 'ContexGin' : 'Fallback'}</span>
<span className="boot-context-pill-chevron">{expanded ? '\u25BE' : '\u25B8'}</span>
</button>
{expanded && (
<div className="boot-context-pill-content">
{context.sources.map((src, idx) => (
<div key={idx} className="boot-context-pill-source">
{src}
</div>
))}
{context.trimmedCount > 0 && (
<div className="boot-context-pill-trimmed">
{context.trimmedCount} section{context.trimmedCount !== 1 ? 's' : ''} trimmed
</div>
)}
</div>
)}
</div>
);
}
8 changes: 7 additions & 1 deletion frontend/src/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<
Expand All @@ -36,6 +38,8 @@ export interface ChatAreaProps {
scrollRef?: React.RefObject<HTMLDivElement | null>;
/** 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<string, ProgressBlock>;
/** Voice capabilities for per-block read-aloud */
Expand All @@ -50,6 +54,7 @@ export function ChatArea({
onPermissionRespond,
scrollRef: externalScrollRef,
sessionContext,
bootContext,
progressByToolId,
voice,
}: ChatAreaProps) {
Expand Down Expand Up @@ -147,9 +152,10 @@ export function ChatArea({
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{bootContext && <BootContextPill context={bootContext} />}
{sessionContext && <ContextBlock content={sessionContext} />}

{messages.length === 0 && !current && !running && !sessionContext && (
{messages.length === 0 && !current && !running && !sessionContext && !bootContext && (
<p className="chat-empty">Send a message to start</p>
)}

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -267,6 +268,7 @@ export function ChatView() {
onPermissionRespond={handlePermission}
scrollRef={scrollRef}
sessionContext={sessionContext}
bootContext={bootContext}
progressByToolId={progressByToolId}
voice={voice}
/>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/DesktopChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -236,6 +238,8 @@ export function DesktopChatView() {
permission={messages.permission}
onPermissionRespond={handlePermission}
scrollRef={scrollRef}
sessionContext={sessionContext}
bootContext={bootContext}
progressByToolId={progressByToolId}
voice={voice}
/>
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions packages/client/__tests__/messages-slice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
117 changes: 117 additions & 0 deletions packages/client/__tests__/protocol-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: Tests cover missing numeric fields and unknown source, but no test verifies behavior when sources contains non-string elements (e.g. [42, null, {path: 'x'}]). This is the edge the as string[] assertion skips. [fixable]

},
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: [],
},
});
});
});
Loading
Loading