feat: v0.11.0 — SDK settingSources, nested session fix, dynamic models, UX improvements

Core fixes:
- Add settingSources to SDK options so CodePilot loads user/project settings
  like the CLI (fixes WebFetch timeouts, tool permission issues)
- Delete CLAUDECODE env var to prevent nested session detection when
  CodePilot runs alongside Claude Code CLI (fixes 'exited with code 1')
- Remove manual MCP config reading (SDK handles it via settingSources)
- Remove hardcoded timeout env vars (SDK loads from user settings)

UX improvements:
- Remember last selected model across sessions (localStorage)
- Same-project new chat skips directory picker
- File tree auto-refreshes on project switch and after each tool completes
- Left sidebar projects default collapsed (only most recent expanded)
- Right-side file tree defaults collapsed
- Fix file tree icon alignment (spacer size-4 → size-5)
- Dynamic model list from /api/providers/models endpoint
- Provider change dispatches event to refresh model selector
- Improved error messages with provider name and troubleshooting hints
- Fix context button file type matching for extension patterns
- Default tool timeout 120s → 300s
This commit is contained in:
7418 committed 2026-02-24 03:08:17 +08:00
1 parent 31879e0540
commit 9c13f043d0
14 files changed
+362 -114

No files matched your search

+114
View File
@@ -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 上添加"开始执行"按钮。
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.10.11",
"version": "0.11.0",
"private": true,
"author": {
"name": "op7418",
+1 -1
View File
@@ -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
+88
View File
@@ -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<string, { value: string; label: string }[]> = {
'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<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to get models' },
{ status: 500 }
);
}
}
+1
View File
@@ -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);
+2 -2
View File
@@ -246,8 +246,8 @@ export const FileTreeFile = ({
>
{children ?? (
<>
{/* Spacer for alignment */}
<span className="size-4" />
{/* Spacer matching folder chevron button (size-4 icon + p-0.5 padding) */}
<span className="size-5 shrink-0" />
<FileTreeIcon>
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
</FileTreeIcon>
@@ -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);
+4 -1
View File
@@ -38,7 +38,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
const [statusText, setStatusText] = useState<string | undefined>();
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<PermissionRequestEvent | null>(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) => {
+73 -70
View File
@@ -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<string, Record<string, string>> = {
// 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<string, string> = {
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<string, string> = {
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<CommandBadge | null>(null);
const [activeProviderBaseUrl, setActiveProviderBaseUrl] = useState<string | null>(null);
const [dynamicModels, setDynamicModels] = useState<{ value: string; label: string }[]>(DEFAULT_MODEL_OPTIONS);
const [activeProviderName, setActiveProviderName] = useState<string | null>(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)}
>
<span className="text-xs font-mono">{currentModelOption.label}</span>
{activeProviderName && activeProviderName !== 'Anthropic' && (
<span className="text-[9px] text-muted-foreground">{activeProviderName}</span>
)}
<HugeiconsIcon icon={ArrowDown01Icon} className={cn("h-2.5 w-2.5 transition-transform duration-200", modelMenuOpen && "rotate-180")} />
</PromptInputButton>
{modelMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-48 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
{activeProviderName && (
<div className="px-3 py-1.5 text-[10px] text-muted-foreground border-b">
{activeProviderName}
</div>
)}
<div className="py-1">
{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);
}}
>
+18 -6
View File
@@ -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<string> {
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<ChatSession[]>([]);
const [hoveredSession, setHoveredSession] = useState<string | null>(null);
const [deletingSession, setDeletingSession] = useState<string | null>(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 (
+3 -5
View File
@@ -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<string>();
return (
<div className="flex flex-col h-full min-h-0">
@@ -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
+49 -26
View File
@@ -239,6 +239,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
return new ReadableStream<string>({
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<strin
// Ensure SDK subprocess has expanded PATH (consistent with Electron mode)
sdkEnv.PATH = getExpandedPath();
// Remove CLAUDECODE env var to prevent "nested session" detection.
// When CodePilot is launched from within a Claude Code CLI session
// (e.g. during development), the child process inherits this variable
// and the SDK refuses to start.
delete sdkEnv.CLAUDECODE;
// On Windows, auto-detect Git Bash if not already configured
if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) {
const gitBashPath = findGitBash();
@@ -259,9 +268,6 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
}
}
// Try to get config from active provider first
const activeProvider = getActiveProvider();
if (activeProvider && activeProvider.api_key) {
// Clear all existing ANTHROPIC_* variables to prevent conflicts
for (const key of Object.keys(sdkEnv)) {
@@ -323,6 +329,14 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
? 'bypassPermissions'
: ((permissionMode as Options['permissionMode']) || 'acceptEdits'),
env: sanitizeEnv(sdkEnv),
// Load settings so the SDK behaves like the CLI (tool permissions,
// CLAUDE.md, etc.). When an active provider is configured in
// CodePilot, skip 'user' settings because ~/.claude/settings.json
// may contain env overrides (ANTHROPIC_BASE_URL, ANTHROPIC_MODEL,
// etc.) that would conflict with the provider's configuration.
settingSources: activeProvider?.api_key
? ['project', 'local']
: ['user', 'project', 'local'],
};
if (skipPermissions) {
@@ -362,28 +376,11 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
};
}
// Load MCP servers: use passed-in config, or auto-read from config files
let effectiveMcpServers = mcpServers;
if (!effectiveMcpServers || Object.keys(effectiveMcpServers).length === 0) {
try {
const userConfigPath = path.join(os.homedir(), '.claude.json');
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
let merged: Record<string, MCPServerConfig> = {};
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<strin
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const rawMessage = error instanceof Error ? error.message : 'Unknown error';
let errorMessage = rawMessage;
// Provide more specific error messages based on error type
if (error instanceof Error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || rawMessage.includes('ENOENT') || rawMessage.includes('spawn')) {
errorMessage = `Claude Code CLI not found. Please ensure Claude Code is installed and available in your PATH.\n\nOriginal error: ${rawMessage}`;
} else if (rawMessage.includes('exited with code 1') || rawMessage.includes('exit code 1')) {
const providerHint = activeProvider?.name ? ` (Provider: ${activeProvider.name})` : '';
errorMessage = `Claude Code process exited with an error${providerHint}. This is often caused by:\n• Invalid or missing API Key\n• Incorrect Base URL configuration\n• Network connectivity issues\n\nOriginal error: ${rawMessage}`;
} else if (rawMessage.includes('exited with code')) {
const providerHint = activeProvider?.name ? ` (Provider: ${activeProvider.name})` : '';
errorMessage = `Claude Code process crashed unexpectedly${providerHint}.\n\nOriginal error: ${rawMessage}`;
} else if (code === 'ECONNREFUSED' || rawMessage.includes('ECONNREFUSED') || rawMessage.includes('fetch failed')) {
const baseUrl = activeProvider?.base_url || 'default';
errorMessage = `Cannot connect to API endpoint (${baseUrl}). Please check your network connection and Base URL configuration.\n\nOriginal error: ${rawMessage}`;
} else if (rawMessage.includes('401') || rawMessage.includes('Unauthorized') || rawMessage.includes('authentication')) {
const providerHint = activeProvider?.name ? ` for provider "${activeProvider.name}"` : '';
errorMessage = `Authentication failed${providerHint}. Please verify your API Key is correct and has not expired.\n\nOriginal error: ${rawMessage}`;
} else if (rawMessage.includes('403') || rawMessage.includes('Forbidden')) {
errorMessage = `Access denied. Your API Key may not have permission for this operation.\n\nOriginal error: ${rawMessage}`;
} else if (rawMessage.includes('429') || rawMessage.includes('rate limit') || rawMessage.includes('Rate limit')) {
errorMessage = `Rate limit exceeded. Please wait a moment before retrying.\n\nOriginal error: ${rawMessage}`;
}
}
controller.enqueue(formatSSE({ type: 'error', data: errorMessage }));
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();