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:
1 parent
31879e0540
commit
9c13f043d0
14 files changed
+362
-114
No files matched your search
@@ -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 上添加"开始执行"按钮。
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.10.11",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user