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:
@@ -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