diff --git a/CLAUDE.md b/CLAUDE.md index 5ada8b4..37b3338 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,3 +91,117 @@ CodePilot — Claude Code 的桌面 GUI 客户端,基于 Electron + Next.js。 - 构建前清理 `rm -rf release/ .next/` 可避免旧产物污染 - 构建 Windows 包后需要 `npm rebuild better-sqlite3` 恢复本地开发环境 - macOS 交叉编译 Windows 需要 Wine(Apple Silicon 上可能不可用),可用 zip 替代 NSIS + +## v0.11.0 Changelog + +### 核心修复:SDK settingSources 配置 + +**问题**:CodePilot 通过 SDK 调用 Claude Code 时未设置 `settingSources`,导致 SDK 运行在隔离模式,不加载用户的 `~/.claude/settings.json` 和项目级 `.claude/settings.json`。这造成工具权限、超时配置、CLAUDE.md 项目记忆等全部缺失,是 WebFetch 超时、工具行为与 CLI 不一致等问题的根因。 + +**修复**:在 `queryOptions` 中添加 `settingSources: ['project', 'local']`(有 Provider 时)或 `['user', 'project', 'local']`(无 Provider 时)。有 Provider 时跳过 `user` 是因为 `~/.claude/settings.json` 中的 `env` 字段(如 `ANTHROPIC_BASE_URL`)会覆盖 CodePilot Provider 的配置。 + +**文件**:`src/lib/claude-client.ts` + +### 核心修复:CLAUDECODE 嵌套会话检测 + +**问题**:当用户从 Claude Code CLI 内启动 CodePilot(开发模式或日常同时使用),子进程继承 `CLAUDECODE` 环境变量,SDK 检测到后拒绝启动,报错 "Claude Code cannot be launched inside another Claude Code session"。这是大量用户报告 "exited with code 1" 的重要原因之一。 + +**修复**:在构建 SDK env 时 `delete sdkEnv.CLAUDECODE`。 + +**文件**:`src/lib/claude-client.ts` + +### 移除手动 MCP 配置读取 + +**原因**:之前 SDK 在隔离模式下不加载设置文件,所以 CodePilot 手动读取 `~/.claude.json` 和 `~/.claude/settings.json` 中的 `mcpServers`。现在 SDK 通过 `settingSources` 自动加载,手动读取会导致 MCP 服务器重复注册。 + +**修复**:移除手动读取逻辑,只传递 CodePilot UI 中显式配置的 MCP 服务器。 + +**文件**:`src/lib/claude-client.ts` + +### 移除硬编码超时环境变量 + +**原因**:之前为解决工具超时问题,硬编码了 `API_TIMEOUT_MS=600000` 和 `CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=600`。现在 SDK 从用户设置中正确加载这些配置,硬编码不再需要。 + +**文件**:`src/lib/claude-client.ts` + +### 改善错误信息展示 + +**问题**:Claude Code 进程报错时只显示原始 error.message,用户无法判断原因。 + +**修复**:根据错误类型(ENOENT、exit code 1、ECONNREFUSED、401/403/429 等)提供具体的错误描述和排查建议,包含 Provider 名称。 + +**文件**:`src/lib/claude-client.ts` + +### 切换项目时自动刷新右侧文件树 + +**问题**:用户在左侧切换到不同项目的聊天时,右侧 FileTree 没有自动刷新。 + +**修复**:在 `ChatSessionPage` 的 `loadSession` 中,`setWorkingDirectory` 后 dispatch `refresh-file-tree` 事件。 + +**文件**:`src/app/chat/[id]/page.tsx` + +### 工具完成后实时刷新文件树 + +**问题**:AI 写入新文件后,文件树要等整个对话结束才刷新,且可能因缓存不显示新文件。 + +**修复**:在 `onToolResult` 回调中 dispatch `refresh-file-tree` 事件;fetch 请求添加 `_t=Date.now()` 防止缓存。 + +**文件**:`src/components/chat/ChatView.tsx`、`src/components/project/FileTree.tsx` + +### 同项目新建聊天不再弹出目录选择 + +**问题**:在某个项目的聊天页面点新建,即使当前已有 workingDirectory,仍弹出 FolderPicker。 + +**修复**:`handleNewChat` 优先使用当前 `workingDirectory`(从 `usePanel` 获取),其次 localStorage,最后才弹 FolderPicker。 + +**文件**:`src/components/layout/ChatListPanel.tsx` + +### 记住上次选择的模型 + +**问题**:每次新建聊天模型都重置为 sonnet。 + +**修复**:模型切换时保存到 `localStorage('codepilot:last-model')`;ChatView 初始化时从 localStorage 读取;新建 session 时传入 last-model。 + +**文件**:`src/components/chat/MessageInput.tsx`、`src/components/chat/ChatView.tsx`、`src/components/layout/ChatListPanel.tsx` + +### 左侧项目文件夹默认收起 + +**问题**:左侧 ChatListPanel 的项目文件夹默认全部展开,项目多时很乱。 + +**修复**:首次使用时(通过 `codepilot:collapsed-initialized` 标记),自动折叠除最近活跃项目外的所有项目组。右侧 FileTree 的 `defaultExpanded` 改为空 Set。 + +**文件**:`src/components/layout/ChatListPanel.tsx`、`src/components/project/FileTree.tsx` + +### 修复右侧文件树图标对齐 + +**问题**:文件名和图标无法垂直对齐。 + +**修复**:文件行的 spacer 从 `size-4` 改为 `size-5 shrink-0`,匹配文件夹 chevron 按钮的实际尺寸(size-4 icon + p-0.5 padding = 20px)。 + +**文件**:`src/components/ai-elements/file-tree.tsx` + +### 动态模型列表 + Provider 切换通知 + +**问题**:模型列表写死为 sonnet/opus/haiku 三个选项,切换 Provider 后模型名称不更新。 + +**修复**:新增 `/api/providers/models` 端点,根据当前激活 Provider 返回对应模型列表;MessageInput 动态加载模型列表;ProviderManager 激活 Provider 后 dispatch `provider-changed` 事件触发刷新。 + +**文件**:`src/app/api/providers/models/route.ts`(新增)、`src/components/chat/MessageInput.tsx`、`src/components/settings/ProviderManager.tsx` + +### 修复上下文按钮和文件类型匹配 + +**问题**:右侧文件树的 "+" 按钮在某些状态下不起作用;扩展名格式的文件类型(如 `.ts`)无法正确匹配。 + +**修复**:在 PromptInput 的文件类型匹配逻辑中添加对 `.` 开头扩展名模式的处理。 + +**文件**:`src/components/ai-elements/prompt-input.tsx` + +### 工具超时时间调整 + +**修复**:默认 toolTimeout 从 120s 调整为 300s。 + +**文件**:`src/app/api/chat/route.ts` + +### 待实现功能 + +- **Plan Mode 自动切换执行**:SDK 的 `Query` 接口支持 `setPermissionMode()`,可在 plan 完成后切换到 code mode 开始执行。需要新增前端→后端的通信通道(类似现有的 permission API),保存 `conversation` 引用,在 UI 上添加"开始执行"按钮。 diff --git a/package-lock.json b/package-lock.json index 239659b..bfdcabf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.10.11", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.10.11", + "version": "0.11.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@hugeicons/core-free-icons": "^3.1.1", diff --git a/package.json b/package.json index e7cdce9..fd7d0a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.10.11", + "version": "0.11.0", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 19e4a2f..83dfc5c 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -110,7 +110,7 @@ export async function POST(request: NextRequest) { abortController, permissionMode, files: fileAttachments, - toolTimeoutSeconds: toolTimeout || 120, + toolTimeoutSeconds: toolTimeout || 300, }); // Tee the stream: one for client, one for collecting the response diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts new file mode 100644 index 0000000..d430633 --- /dev/null +++ b/src/app/api/providers/models/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server'; +import { getActiveProvider } from '@/lib/db'; +import type { ErrorResponse } from '@/types'; + +// Default Claude model options +const DEFAULT_MODELS = [ + { value: 'sonnet', label: 'Sonnet 4.5' }, + { value: 'opus', label: 'Opus 4.6' }, + { value: 'haiku', label: 'Haiku 4.5' }, +]; + +// Provider-specific model label mappings (base_url -> alias -> display name) +const PROVIDER_MODEL_LABELS: Record = { + 'https://api.z.ai/api/anthropic': [ + { value: 'sonnet', label: 'GLM-4.7' }, + { value: 'opus', label: 'GLM-4.7' }, + { value: 'haiku', label: 'GLM-4.5-Air' }, + ], + 'https://open.bigmodel.cn/api/anthropic': [ + { value: 'sonnet', label: 'GLM-4.7' }, + { value: 'opus', label: 'GLM-4.7' }, + { value: 'haiku', label: 'GLM-4.5-Air' }, + ], + 'https://api.kimi.com/coding/': [ + { value: 'sonnet', label: 'Kimi K2.5' }, + { value: 'opus', label: 'Kimi K2.5' }, + { value: 'haiku', label: 'Kimi K2.5' }, + ], + 'https://api.moonshot.ai/anthropic': [ + { value: 'sonnet', label: 'Kimi K2.5' }, + { value: 'opus', label: 'Kimi K2.5' }, + { value: 'haiku', label: 'Kimi K2.5' }, + ], + 'https://api.moonshot.cn/anthropic': [ + { value: 'sonnet', label: 'Kimi K2.5' }, + { value: 'opus', label: 'Kimi K2.5' }, + { value: 'haiku', label: 'Kimi K2.5' }, + ], + 'https://api.minimaxi.com/anthropic': [ + { value: 'sonnet', label: 'MiniMax-M2.1' }, + { value: 'opus', label: 'MiniMax-M2.1' }, + { value: 'haiku', label: 'MiniMax-M2.1' }, + ], + 'https://api.minimax.io/anthropic': [ + { value: 'sonnet', label: 'MiniMax-M2.1' }, + { value: 'opus', label: 'MiniMax-M2.1' }, + { value: 'haiku', label: 'MiniMax-M2.1' }, + ], + 'https://openrouter.ai/api': [ + { value: 'sonnet', label: 'Sonnet 4.5' }, + { value: 'opus', label: 'Opus 4.6' }, + { value: 'haiku', label: 'Haiku 4.5' }, + ], +}; + +export async function GET() { + try { + const activeProvider = getActiveProvider(); + + // No active provider or anthropic type -> default Claude models + if (!activeProvider || activeProvider.provider_type === 'anthropic') { + return NextResponse.json({ + models: DEFAULT_MODELS, + provider_name: activeProvider?.name || 'Anthropic', + }); + } + + // Custom/other provider -> match by base_url + const matched = PROVIDER_MODEL_LABELS[activeProvider.base_url]; + if (matched) { + return NextResponse.json({ + models: matched, + provider_name: activeProvider.name, + }); + } + + // Unknown provider -> return defaults with provider name + return NextResponse.json({ + models: DEFAULT_MODELS, + provider_name: activeProvider.name, + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get models' }, + { status: 500 } + ); + } +} diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index bc1ab67..7bb717a 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -81,6 +81,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { if (data.session.working_directory) { setWorkingDirectory(data.session.working_directory); localStorage.setItem("codepilot:last-working-directory", data.session.working_directory); + window.dispatchEvent(new Event('refresh-file-tree')); } setSessionId(id); setPanelOpen(true); diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index f6a5da8..8516447 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -246,8 +246,8 @@ export const FileTreeFile = ({ > {children ?? ( <> - {/* Spacer for alignment */} - + {/* Spacer matching folder chevron button (size-4 icon + p-0.5 padding) */} + {icon ?? } diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx index 6d5dfb3..599de7c 100644 --- a/src/components/ai-elements/prompt-input.tsx +++ b/src/components/ai-elements/prompt-input.tsx @@ -441,6 +441,10 @@ export const PromptInput = ({ .filter(Boolean); return patterns.some((pattern) => { + if (pattern.startsWith(".")) { + // Extension pattern (e.g. ".ts", ".json") — match against filename + return f.name.toLowerCase().endsWith(pattern.toLowerCase()); + } if (pattern.endsWith("/*")) { // e.g: image/* -> image/ const prefix = pattern.slice(0, -1); diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 9993405..de1d3f5 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -38,7 +38,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const [toolResults, setToolResults] = useState([]); const [statusText, setStatusText] = useState(); const [mode, setMode] = useState(initialMode || 'code'); - const [currentModel, setCurrentModel] = useState(modelName || 'sonnet'); + const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet'); const [pendingPermission, setPendingPermission] = useState(null); const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null); const [streamingToolOutput, setStreamingToolOutput] = useState(''); @@ -235,6 +235,9 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal onToolResult: (res) => { setStreamingToolOutput(''); setToolResults((prev) => [...prev, res]); + // Refresh file tree after each tool completes — file writes, + // deletions, and other FS operations are done via tools. + window.dispatchEvent(new Event('refresh-file-tree')); }, onToolOutput: (data) => { setStreamingToolOutput((prev) => { diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 1e95b72..c8a6bb9 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -116,62 +116,13 @@ const MODE_OPTIONS: ModeOption[] = [ { value: 'ask', label: 'Ask', icon: HelpCircleIcon, description: 'Answer questions only' }, ]; -// Default Claude model options — labels are dynamically overridden by active provider +// Default Claude model options — used as fallback when API is unavailable const DEFAULT_MODEL_OPTIONS = [ { value: 'sonnet', label: 'Sonnet 4.5' }, { value: 'opus', label: 'Opus 4.6' }, { value: 'haiku', label: 'Haiku 4.5' }, ]; -// Provider-specific model label mappings (alias → display name) -const PROVIDER_MODEL_LABELS: Record> = { - // GLM Coding Plan (Z.AI / 智谱) - 'https://api.z.ai/api/anthropic': { - sonnet: 'GLM-4.7', - opus: 'GLM-4.7', - haiku: 'GLM-4.5-Air', - }, - 'https://open.bigmodel.cn/api/anthropic': { - sonnet: 'GLM-4.7', - opus: 'GLM-4.7', - haiku: 'GLM-4.5-Air', - }, - // Kimi Coding Plan - 'https://api.kimi.com/coding/': { - sonnet: 'Kimi K2.5', - opus: 'Kimi K2.5', - haiku: 'Kimi K2.5', - }, - // Moonshot Open Platform - 'https://api.moonshot.ai/anthropic': { - sonnet: 'Kimi K2.5', - opus: 'Kimi K2.5', - haiku: 'Kimi K2.5', - }, - 'https://api.moonshot.cn/anthropic': { - sonnet: 'Kimi K2.5', - opus: 'Kimi K2.5', - haiku: 'Kimi K2.5', - }, - // MiniMax Coding Plan - 'https://api.minimaxi.com/anthropic': { - sonnet: 'MiniMax-M2.1', - opus: 'MiniMax-M2.1', - haiku: 'MiniMax-M2.1', - }, - 'https://api.minimax.io/anthropic': { - sonnet: 'MiniMax-M2.1', - opus: 'MiniMax-M2.1', - haiku: 'MiniMax-M2.1', - }, - // OpenRouter — keeps Claude names, provider handles routing - 'https://openrouter.ai/api': { - sonnet: 'Sonnet 4.5', - opus: 'Opus 4.6', - haiku: 'Haiku 4.5', - }, -}; - /** * Convert a data URL to a FileAttachment object. */ @@ -247,6 +198,44 @@ function AttachFileButton() { ); } +/** + * Infer a MIME type from a filename extension so that files added from the + * file tree pass the PromptInput accept-type validation. Code / text files + * are mapped to `text/*` subtypes; images and PDFs get their standard types. + * Falls back to `application/octet-stream` for unknown extensions. + */ +function mimeFromFilename(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() || ''; + const TEXT_EXTS: Record = { + md: 'text/markdown', mdx: 'text/markdown', + txt: 'text/plain', csv: 'text/csv', + json: 'application/json', + ts: 'text/typescript', tsx: 'text/typescript', + js: 'text/javascript', jsx: 'text/javascript', + py: 'text/x-python', go: 'text/x-go', rs: 'text/x-rust', + rb: 'text/x-ruby', java: 'text/x-java', c: 'text/x-c', + cpp: 'text/x-c++', h: 'text/x-c', hpp: 'text/x-c++', + cs: 'text/x-csharp', swift: 'text/x-swift', kt: 'text/x-kotlin', + html: 'text/html', css: 'text/css', scss: 'text/css', + xml: 'text/xml', yaml: 'text/yaml', yml: 'text/yaml', + toml: 'text/plain', ini: 'text/plain', cfg: 'text/plain', + sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript', + sql: 'text/x-sql', graphql: 'text/plain', gql: 'text/plain', + vue: 'text/plain', svelte: 'text/plain', astro: 'text/plain', + env: 'text/plain', gitignore: 'text/plain', dockerignore: 'text/plain', + dockerfile: 'text/plain', makefile: 'text/plain', + log: 'text/plain', lock: 'text/plain', + }; + const IMAGE_EXTS: Record = { + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', + gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', + }; + if (TEXT_EXTS[ext]) return TEXT_EXTS[ext]; + if (IMAGE_EXTS[ext]) return IMAGE_EXTS[ext]; + if (ext === 'pdf') return 'application/pdf'; + return 'application/octet-stream'; +} + /** * Bridge component that listens for 'attach-file-to-chat' custom events * from the file tree and adds files as attachments. Must be rendered inside PromptInput. @@ -274,7 +263,10 @@ function FileTreeAttachmentBridge() { const blob = await res.blob(); // Handle both Unix (/) and Windows (\) path separators const filename = filePath.split(/[/\\]/).pop() || 'file'; - const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' }); + // Use a proper MIME type derived from the extension so the file + // passes PromptInput's accept-type validation (text/* etc.) + const mime = mimeFromFilename(filename); + const file = new File([blob], filename, { type: mime }); attachmentsRef.current.add([file]); } catch (err) { console.warn('[FileTreeAttachment] Error attaching file:', filePath, err); @@ -358,34 +350,36 @@ export function MessageInput({ const [modelMenuOpen, setModelMenuOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const [badge, setBadge] = useState(null); - const [activeProviderBaseUrl, setActiveProviderBaseUrl] = useState(null); + const [dynamicModels, setDynamicModels] = useState<{ value: string; label: string }[]>(DEFAULT_MODEL_OPTIONS); const [activeProviderName, setActiveProviderName] = useState(null); - // Fetch active provider to adapt model labels - useEffect(() => { - fetch('/api/providers') + // Fetch models from API + const fetchModels = useCallback(() => { + fetch('/api/providers/models') .then((r) => r.json()) .then((data) => { - const active = (data.providers || []).find((p: { is_active: number }) => p.is_active === 1); - if (active) { - setActiveProviderBaseUrl(active.base_url || null); - setActiveProviderName(active.name || null); + if (data.models && data.models.length > 0) { + setDynamicModels(data.models); } else { - setActiveProviderBaseUrl(null); - setActiveProviderName(null); + setDynamicModels(DEFAULT_MODEL_OPTIONS); } + setActiveProviderName(data.provider_name || null); }) - .catch(() => {}); + .catch(() => { + setDynamicModels(DEFAULT_MODEL_OPTIONS); + setActiveProviderName(null); + }); }, []); - // Compute model options based on active provider - const MODEL_OPTIONS = DEFAULT_MODEL_OPTIONS.map((opt) => { - if (activeProviderBaseUrl && PROVIDER_MODEL_LABELS[activeProviderBaseUrl]) { - const label = PROVIDER_MODEL_LABELS[activeProviderBaseUrl][opt.value]; - if (label) return { ...opt, label }; - } - return opt; - }); + // Load models on mount and listen for provider changes + useEffect(() => { + fetchModels(); + const handler = () => fetchModels(); + window.addEventListener('provider-changed', handler); + return () => window.removeEventListener('provider-changed', handler); + }, [fetchModels]); + + const MODEL_OPTIONS = dynamicModels; // Fetch files for @ mention const fetchFiles = useCallback(async (filter: string) => { @@ -960,11 +954,19 @@ export function MessageInput({ onClick={() => setModelMenuOpen((prev) => !prev)} > {currentModelOption.label} + {activeProviderName && activeProviderName !== 'Anthropic' && ( + {activeProviderName} + )} {modelMenuOpen && (
+ {activeProviderName && ( +
+ {activeProviderName} +
+ )}
{MODEL_OPTIONS.map((opt) => { const isActive = opt.value === currentModelValue; @@ -977,6 +979,7 @@ export function MessageInput({ )} onClick={() => { onModelChange?.(opt.value); + localStorage.setItem('codepilot:last-model', opt.value); setModelMenuOpen(false); }} > diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 4dd6bf4..2b2ae67 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -51,6 +51,7 @@ function formatRelativeTime(dateStr: string): string { } const COLLAPSED_PROJECTS_KEY = "codepilot:collapsed-projects"; +const COLLAPSED_INITIALIZED_KEY = "codepilot:collapsed-initialized"; function loadCollapsedProjects(): Set { if (typeof window === 'undefined') return new Set(); @@ -116,7 +117,7 @@ const MODE_BADGE_CONFIG = { export function ChatListPanel({ open, width }: ChatListPanelProps) { const pathname = usePathname(); const router = useRouter(); - const { streamingSessionId, pendingApprovalSessionId } = usePanel(); + const { streamingSessionId, pendingApprovalSessionId, workingDirectory } = usePanel(); const [sessions, setSessions] = useState([]); const [hoveredSession, setHoveredSession] = useState(null); const [deletingSession, setDeletingSession] = useState(null); @@ -130,9 +131,8 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const [creatingChat, setCreatingChat] = useState(false); const handleNewChat = useCallback(async () => { - const lastDir = typeof window !== 'undefined' - ? localStorage.getItem("codepilot:last-working-directory") - : null; + const lastDir = workingDirectory + || (typeof window !== 'undefined' ? localStorage.getItem("codepilot:last-working-directory") : null); if (!lastDir) { // No saved directory — let user pick one @@ -156,7 +156,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const res = await fetch("/api/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ working_directory: lastDir }), + body: JSON.stringify({ working_directory: lastDir, model: localStorage.getItem('codepilot:last-model') || '' }), }); if (!res.ok) { // Backend rejected it (e.g. INVALID_DIRECTORY) — prompt user @@ -172,7 +172,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { } finally { setCreatingChat(false); } - }, [router]); + }, [router, workingDirectory]); const toggleProject = useCallback((wd: string) => { setCollapsedProjects((prev) => { @@ -295,6 +295,18 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { [filteredSessions] ); + // On first use, auto-collapse all project groups except the most recent one + useEffect(() => { + if (projectGroups.length <= 1) return; + if (localStorage.getItem(COLLAPSED_INITIALIZED_KEY)) return; + const toCollapse = new Set( + projectGroups.slice(1).map((g) => g.workingDirectory) + ); + setCollapsedProjects(toCollapse); + saveCollapsedProjects(toCollapse); + localStorage.setItem(COLLAPSED_INITIALIZED_KEY, "1"); + }, [projectGroups]); + if (!open) return null; return ( diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 1f23daf..2b427d5 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -118,7 +118,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree setLoading(true); try { const res = await fetch( - `/api/files?dir=${encodeURIComponent(workingDirectory)}&baseDir=${encodeURIComponent(workingDirectory)}&depth=4` + `/api/files?dir=${encodeURIComponent(workingDirectory)}&baseDir=${encodeURIComponent(workingDirectory)}&depth=4&_t=${Date.now()}` ); if (res.ok) { const data = await res.json(); @@ -144,10 +144,8 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree return () => window.removeEventListener('refresh-file-tree', handler); }, [fetchTree]); - // Build default expanded set from first-level directories - const defaultExpanded = new Set( - tree.filter((n) => n.type === "directory").map((n) => n.path) - ); + // Default to all directories collapsed + const defaultExpanded = new Set(); return (
diff --git a/src/components/settings/ProviderManager.tsx b/src/components/settings/ProviderManager.tsx index 21d8c92..dc2c9fb 100644 --- a/src/components/settings/ProviderManager.tsx +++ b/src/components/settings/ProviderManager.tsx @@ -164,6 +164,7 @@ export function ProviderManager() { is_active: p.id === provider.id ? 1 : 0, })) ); + window.dispatchEvent(new Event('provider-changed')); } } catch { // ignore @@ -184,6 +185,7 @@ export function ProviderManager() { setProviders((prev) => prev.map((p) => ({ ...p, is_active: 0 })) ); + window.dispatchEvent(new Event('provider-changed')); } } catch { // ignore diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 4054262..b9dddab 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -239,6 +239,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream({ async start(controller) { + // Hoist activeProvider so it's accessible in the catch block for error messages + const activeProvider = getActiveProvider(); + try { // Build env for the Claude Code subprocess. // Start with process.env (includes user shell env from Electron's loadUserShellEnv). @@ -251,6 +254,12 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream = {}; - if (fs.existsSync(userConfigPath)) { - const userConfig = JSON.parse(fs.readFileSync(userConfigPath, 'utf-8')); - if (userConfig.mcpServers) merged = { ...merged, ...userConfig.mcpServers }; - } - if (fs.existsSync(settingsPath)) { - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - if (settings.mcpServers) merged = { ...merged, ...settings.mcpServers }; - } - if (Object.keys(merged).length > 0) effectiveMcpServers = merged; - } catch { - // ignore config read errors - } - } - if (effectiveMcpServers && Object.keys(effectiveMcpServers).length > 0) { - queryOptions.mcpServers = toSdkMcpConfig(effectiveMcpServers); + // MCP servers: only pass explicitly provided config (e.g. from CodePilot UI). + // User-level MCP config from ~/.claude.json and ~/.claude/settings.json + // is now automatically loaded by the SDK via settingSources: ['user', 'project', 'local']. + if (mcpServers && Object.keys(mcpServers).length > 0) { + queryOptions.mcpServers = toSdkMcpConfig(mcpServers); } // Resume session if we have an SDK session ID from a previous conversation turn @@ -678,7 +675,33 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream