feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)
- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,7 +124,11 @@ export const MessageCompleteFrame = z.object({
|
||||
ctx_max: z.number().int().positive().nullable().optional(),
|
||||
started_at: IsoTimestamp.nullable().optional(),
|
||||
finished_at: IsoTimestamp.nullable().optional(),
|
||||
model: z.string().optional(),
|
||||
// nullable: external-coder turns carry task.model, which is null when no
|
||||
// model was selected. This frame is published through the same fail-closed
|
||||
// publishFrame, so null MUST validate or the entire frame (incl. the
|
||||
// status:'complete' transition) is dropped.
|
||||
model: z.string().nullable().optional(),
|
||||
metadata: OpaqueObject.nullable().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||
import { providerIcon } from '@/components/coder/providerIcons';
|
||||
import { useAgentSessions } from '@/hooks/useAgentSessions';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -174,16 +173,6 @@ interface Props {
|
||||
onChange: (next: AgentSessionConfig) => void;
|
||||
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
|
||||
connected?: boolean;
|
||||
// v2.6 Phase 1-UX §9b: chat id for the resumed/new-session chip. Optional so
|
||||
// BooChat and any other AgentComposerBar caller renders no chip and is
|
||||
// otherwise unaffected. When present + connected + the chat has ≥1 prior
|
||||
// turn, a chip right of the Provider picker reports whether switching to the
|
||||
// current provider resumes an agent session, replays history (boocode), or
|
||||
// starts fresh.
|
||||
sessionId?: string;
|
||||
// True once the chat has at least one prior turn — gates the chip so it stays
|
||||
// hidden on a brand-new chat. Defaults to false (no chip).
|
||||
hasPriorTurn?: boolean;
|
||||
// #10: normalized status (working|blocked|idle|error) for the active external
|
||||
// agent in this chat, or null for native boocode / before any frame. Renders
|
||||
// a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for
|
||||
@@ -191,31 +180,6 @@ interface Props {
|
||||
agentStatus?: AgentStatusEntry | null;
|
||||
}
|
||||
|
||||
// Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M".
|
||||
// Sub-1000 stays exact; thousands/millions get one decimal, trailing .0 trimmed.
|
||||
function abbrevTokens(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 1000) return String(Math.max(0, Math.round(n)));
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`;
|
||||
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
||||
}
|
||||
|
||||
// Relative-time formatter for the resumed-chip title (e.g. "3m ago").
|
||||
function relativeTime(iso: string | null): string {
|
||||
if (!iso) return 'unknown';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return 'unknown';
|
||||
const diffMs = Date.now() - then;
|
||||
if (diffMs < 0) return 'just now';
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
// #10: normalized external-agent status dot. Mirrors StatusDot's visual
|
||||
// language but on the four normalized buckets (working|blocked|idle|error),
|
||||
// and is DISTINCT from the WS-liveness `connected` dot beside it:
|
||||
@@ -251,7 +215,7 @@ function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: stri
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn, agentStatus }: Props) {
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus }: Props) {
|
||||
const allEntries = useProviderSnapshot(projectPath);
|
||||
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
||||
@@ -263,13 +227,6 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows for the resumed/new
|
||||
// chip. Hook is unconditional (hooks rule); it self-no-ops when sessionId is
|
||||
// undefined or the chat has no prior turn, so BooChat callers cost nothing.
|
||||
const { sessions: agentSessions } = useAgentSessions(
|
||||
sessionId && hasPriorTurn ? sessionId : undefined,
|
||||
);
|
||||
|
||||
const hydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -383,42 +340,8 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
|
||||
|
||||
// v2.6 Phase 1-UX §9b: resumed / history / new-session chip. Only meaningful
|
||||
// when this is a real chat (sessionId), the WS is connected, and the chat has
|
||||
// ≥1 prior turn — otherwise render nothing so fresh chats and non-coder
|
||||
// callers stay clean.
|
||||
const sessionRow = agentSessions.find((s) => s.agent === value.provider);
|
||||
const sessionChip: { label: string; title: string } | null =
|
||||
sessionId && hasPriorTurn && connected
|
||||
? value.provider === 'boocode'
|
||||
? // Native boocode never holds an agent_sessions row — it reconstructs
|
||||
// the conversation from the chat transcript each turn.
|
||||
{ label: 'history', title: 'BooCode replays the chat transcript each turn' }
|
||||
: sessionRow?.has_session
|
||||
? {
|
||||
label: 'resumed',
|
||||
title: `Resuming ${value.provider} · last active ${relativeTime(sessionRow.last_active_at)}`,
|
||||
}
|
||||
: { label: 'new session', title: `${value.provider} starts a fresh session this turn` }
|
||||
: null;
|
||||
|
||||
// sampling-streamjson-tokens #8: condensed per-(chat,agent) token/cost readout
|
||||
// beside the session chip. Coerce — input/output are BIGINT (string over wire).
|
||||
// Hidden when no session row or all totals are zero (e.g. native boocode, which
|
||||
// holds no agent_sessions row, or a provider that hasn't run yet).
|
||||
const usageReadout = (() => {
|
||||
if (!sessionChip || !sessionRow) return null;
|
||||
const inTok = Number(sessionRow.input_tokens) || 0;
|
||||
const outTok = Number(sessionRow.output_tokens) || 0;
|
||||
const cost = Number(sessionRow.cost) || 0;
|
||||
if (inTok <= 0 && outTok <= 0 && cost <= 0) return null;
|
||||
const parts = [`${abbrevTokens(inTok)} in`, `${abbrevTokens(outTok)} out`];
|
||||
if (cost > 0) parts.push(`$${cost.toFixed(2)}`);
|
||||
return parts.join(' · ');
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
||||
<CompactPicker
|
||||
label="Provider"
|
||||
value={value.provider}
|
||||
@@ -430,22 +353,6 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
: providerIcon(value.provider)
|
||||
}
|
||||
/>
|
||||
{sessionChip && (
|
||||
<span
|
||||
title={sessionChip.title}
|
||||
className="inline-flex items-center rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shrink-0"
|
||||
>
|
||||
{sessionChip.label}
|
||||
</span>
|
||||
)}
|
||||
{usageReadout && (
|
||||
<span
|
||||
className="text-[10px] text-muted-foreground tabular-nums whitespace-nowrap shrink-0"
|
||||
title="Tokens in · out · cost for this agent session"
|
||||
>
|
||||
{usageReadout}
|
||||
</span>
|
||||
)}
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
value={value.modeId ?? ''}
|
||||
@@ -472,8 +379,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
icon={<Brain className="size-3 shrink-0" />}
|
||||
/>
|
||||
)}
|
||||
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
||||
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||
{/* Status dot + refresh — pinned right (ml-auto), never on its own line. */}
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{/* #10: normalized agent status — only for an external agent with a
|
||||
live status frame. Distinct from the WS-liveness dot that follows. */}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CoderMessageWire {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
model?: string | null;
|
||||
reasoning_text?: string;
|
||||
tool_calls?: CoderToolCallWire[];
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ interface CoderMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
// model-attribution: which model produced this assistant message (chip).
|
||||
model?: string | null;
|
||||
reasoning_text?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
@@ -52,6 +54,46 @@ interface CoderToolMessage {
|
||||
|
||||
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
|
||||
|
||||
// Per-chat agent-config cache (provider/model/mode/thinking). Keyed by chat id
|
||||
// so reopening or switching back to a chat restores the model that was loaded
|
||||
// last there. Per-device (localStorage) — a UI convenience, not authoritative.
|
||||
const DEFAULT_AGENT_CONFIG: AgentSessionConfig = {
|
||||
provider: 'boocode',
|
||||
model: '',
|
||||
modeId: null,
|
||||
thinkingOptionId: null,
|
||||
};
|
||||
function agentConfigKey(chatId: string | undefined): string | null {
|
||||
return chatId ? `boocode.coder.config.${chatId}` : null;
|
||||
}
|
||||
function readCachedAgentConfig(chatId: string | undefined): AgentSessionConfig | null {
|
||||
const key = agentConfigKey(chatId);
|
||||
if (!key || typeof localStorage === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const c = JSON.parse(raw) as Partial<AgentSessionConfig>;
|
||||
if (typeof c?.provider !== 'string') return null;
|
||||
return {
|
||||
provider: c.provider,
|
||||
model: typeof c.model === 'string' ? c.model : '',
|
||||
modeId: c.modeId ?? null,
|
||||
thinkingOptionId: c.thinkingOptionId ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function writeCachedAgentConfig(chatId: string | undefined, config: AgentSessionConfig): void {
|
||||
const key = agentConfigKey(chatId);
|
||||
if (!key || typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(config));
|
||||
} catch {
|
||||
/* quota / disabled storage — non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingChange {
|
||||
id: string;
|
||||
file_path: string;
|
||||
@@ -97,6 +139,7 @@ type RawCoderMessage = {
|
||||
chat_id?: string;
|
||||
content?: string | null;
|
||||
status?: string | null;
|
||||
model?: string | null;
|
||||
reasoning_text?: string;
|
||||
reasoning_parts?: Array<{ text?: string }> | null;
|
||||
tool_results?: {
|
||||
@@ -144,6 +187,7 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
|
||||
role: raw.role as CoderMessage['role'],
|
||||
content: raw.content ?? '',
|
||||
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
||||
...(raw.model ? { model: raw.model } : {}),
|
||||
...(reasoning_text ? { reasoning_text } : {}),
|
||||
...(tool_calls?.length ? { tool_calls } : {}),
|
||||
ctx_used: raw.ctx_used ?? null,
|
||||
@@ -253,6 +297,7 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
? {
|
||||
...m,
|
||||
status: 'complete' as const,
|
||||
model: (frame as any).model ?? (m as any).model ?? null,
|
||||
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
|
||||
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
|
||||
}
|
||||
@@ -586,12 +631,37 @@ export function CoderPane({
|
||||
onConnectedChange,
|
||||
onAgentLabelChange,
|
||||
}: Props) {
|
||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||
provider: 'boocode',
|
||||
model: '',
|
||||
modeId: null,
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>(
|
||||
() => readCachedAgentConfig(chatId) ?? DEFAULT_AGENT_CONFIG,
|
||||
);
|
||||
// Restore the per-chat cached config when the chat changes. The ref guard
|
||||
// skips the initial mount (lazy init already loaded it) + StrictMode double-runs.
|
||||
const lastLoadedChatRef = useRef<string | undefined>(chatId);
|
||||
useEffect(() => {
|
||||
const prev = lastLoadedChatRef.current;
|
||||
if (prev === chatId) return;
|
||||
lastLoadedChatRef.current = chatId;
|
||||
// undefined → real id: the pane just resolved its chat. A selection made
|
||||
// while chatId was undefined could not be persisted (the key was null), so
|
||||
// carry the current in-memory config into the new chat — and persist it —
|
||||
// rather than clobbering the user's pick with DEFAULT on the cache miss.
|
||||
if (prev === undefined && chatId) {
|
||||
const cached = readCachedAgentConfig(chatId);
|
||||
if (cached) setAgentConfig(cached);
|
||||
else writeCachedAgentConfig(chatId, agentConfig);
|
||||
return;
|
||||
}
|
||||
setAgentConfig(readCachedAgentConfig(chatId) ?? DEFAULT_AGENT_CONFIG);
|
||||
}, [chatId, agentConfig]);
|
||||
// Persist on user-driven changes only (not on the restore above), so switching
|
||||
// chats never clobbers the new chat's cached config with the old one.
|
||||
const handleAgentConfigChange = useCallback(
|
||||
(next: AgentSessionConfig) => {
|
||||
setAgentConfig(next);
|
||||
writeCachedAgentConfig(chatId, next);
|
||||
},
|
||||
[chatId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [agentConfig.provider || 'boocode'];
|
||||
@@ -727,13 +797,6 @@ export function CoderPane({
|
||||
}
|
||||
}, [messages, refresh, refreshCheckpoints, sessionId]);
|
||||
|
||||
// The §9b chip only shows once the chat has ≥1 prior turn (a completed
|
||||
// assistant message). Hidden on a brand-new chat.
|
||||
const hasPriorTurn = useMemo(
|
||||
() => messages.some((m) => m.role === 'assistant' && (m as CoderMessage).status === 'complete'),
|
||||
[messages],
|
||||
);
|
||||
|
||||
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
|
||||
useEffect(() => {
|
||||
if (!activeTaskId || connected) return;
|
||||
@@ -1001,11 +1064,9 @@ export function CoderPane({
|
||||
<AgentComposerBar
|
||||
projectPath={projectPath}
|
||||
value={agentConfig}
|
||||
onChange={setAgentConfig}
|
||||
onChange={handleAgentConfigChange}
|
||||
onProviderCommandsChange={handleProviderCommandsChange}
|
||||
connected={connected}
|
||||
sessionId={sessionId}
|
||||
hasPriorTurn={hasPriorTurn}
|
||||
agentStatus={currentAgentStatus}
|
||||
/>
|
||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||
|
||||
Reference in New Issue
Block a user