feat: add Setup Center onboarding and move commit/push to Git panel

Add a skippable first-use Setup Center that guides users through three
prerequisites: Claude Code CLI detection, API provider configuration,
and default project directory selection. Replace passive fail-then-hint
patterns with proactive guidance and actionable error recovery.

Setup Center (13 new files):
- SetupCenter overlay with 4 cards (Welcome, ClaudeCode, Provider, ProjectDir)
- Auto-close only when user completes last card in current session
- Re-openable from Settings > General > "首次设置引导"
- Claude Code conflict detection with per-install-type uninstall commands
- Provider detection covers DB providers, env vars, and legacy app-settings token
- ANTHROPIC_BASE_URL excluded from credential check (not a real credential)
- skipped != completed: /chat still prompts for provider after skip

Project directory:
- Validation via /api/files/browse before trusting saved paths
- Fallback chain: localStorage → setup_default_project → empty state
- Cross-component sync via project-directory-changed CustomEvent

Toast system:
- Global showToast() API with success/error/warning/info types
- FIFO eviction (max 3), action button support

Git panel:
- Move Commit + Push buttons from UnifiedTopBar into GitStatusSection
- Push enabled for new branches (backend auto-handles -u origin)

Other:
- Windows top-bar safe zone (138px spacer for caption buttons)
- Cross-platform path splitting with /[\\/]/ regex
- Platform-aware file manager name (Finder/Explorer/Files)
- ~80 new i18n keys (en + zh)
- Handover doc: docs/handover/onboarding-setup-center.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-13 15:19:52 +08:00
1 parent e6d707fe21
commit 8154130035
28 files changed
+1852 -148

No files matched your search

+1
View File
@@ -15,3 +15,4 @@
| cli-tools.md | CLI 工具管理:静态 catalog、系统检测、一键安装、AI 描述、聊天上下文注入、输入框选择器 |
| ui-governance.md | 设计模式治理:四层架构、ESLint 规则、图标/颜色统一、组件拆分记录、新增文件清单 |
| git-terminal-layout.md | Git 集成 + 终端 + 统一布局重构:四层布局、Git 后端/前端、终端抽屉、ResizeHandle 统一、已知债务 |
| onboarding-setup-center.md | 首次引导 Setup Center:三卡片引导流程、Claude Code 环境检测与冲突处理、Provider 三条凭据来源、目录校验回退链、Toast 系统、Windows 适配 |
+221
View File
@@ -0,0 +1,221 @@
# 首次引导 Setup Center + Claude Code 环境检测
> 完成时间:2026-03-13 | 涉及 ~25 个文件 | 6 阶段交付
## 概述
为 CodePilot 新增可跳过的首次设置引导(Setup Center),替代原先被动的"失败后提示"模式。引导覆盖三个核心前置条件:Claude Code CLI 连接、API Provider 配置、默认项目目录。同时修复 Windows 顶栏重叠,统一错误反馈路径。
## 架构
### 整体流程
```
应用启动
↓
AppShell.useEffect → GET /api/setup
↓
setup_completed !== true?
├── 是 → 弹出 SetupCenter 蒙层
│ ├── ClaudeCodeCard: 检测 CLI 环境
│ ├── ProviderCard: 检测 API 凭据
│ └── ProjectDirCard: 选择项目目录
│ 用户可逐个完成或跳过,全部完成后自动关闭
└── 否 → 正常进入应用
↓
/chat 页独立校验 provider + 目录
缺失时显示 ChatEmptyState 引导
```
### 组件结构
```
AppShell
└── SetupCenter (fixed overlay, z-50)
├── WelcomeCard (静态)
├── ClaudeCodeCard → /api/claude-status
├── ProviderCard → /api/providers + /api/settings/app
└── ProjectDirCard → /api/setup/recent-projects
```
### 状态持久化
Setup 状态存储在 SQLite settings 表,键名:
| 键 | 值 | 说明 |
|---|---|---|
| `setup_completed` | `'true'` | 整体完成标记,控制是否自动弹出 |
| `setup_claude_skipped` | `'true'` | 用户跳过了 Claude Code 检测 |
| `setup_provider_skipped` | `'true'` | 用户跳过了 Provider 配置 |
| `setup_project_skipped` | `'true'` | 用户跳过了项目目录选择 |
| `setup_default_project` | 路径字符串 | 用户选择的默认项目目录 |
### 重新打开
- 设置页 GeneralSection 中有"首次设置引导"入口(FieldRow + Open 按钮)
- 代码中任何位置可通过 `window.dispatchEvent(new CustomEvent('open-setup-center'))` 触发
- 支持 `initialCard` 参数跳转到指定卡片:`{ detail: { initialCard: 'provider' } }`
### 自动关闭逻辑
- 用 `initialCompletedCountRef` 记录打开时的已完成数
- 只在用户**当前会话中**完成了最后一张卡时才自动关闭
- 已全部完成的状态下手动打开不会自动关(避免从设置页点开瞬间关闭)
## Claude Code 环境检测(详细)
### 检测链路
```
ClaudeCodeCard
↓ GET /api/claude-status
↓
findClaudeBinary() + findAllClaudeBinaries() (src/lib/platform.ts)
↓
返回:
connected: boolean — 是否找到可用二进制
version: string | null — 版本号
binaryPath: string | null — 当前使用的路径
installType: string — native / npm / bun / homebrew / unknown
otherInstalls: Array<{path, version, type}> — 其他冲突安装
missingGit: boolean — Windows 上未检测到 Git
```
### 四种状态及 UI 展示
#### 1. 已检测到(connected = true, 无冲突)
- 绿色 completed 状态
- 显示版本号、安装类型、二进制路径
#### 2. 已检测到但存在冲突(connected = true, otherInstalls.length > 0)
- 黄色警告框,标题:"检测到多个安装版本,可能导致版本冲突"
- 显示当前使用的版本(路径 + 安装类型 + 版本号)
- "查看清理方式"按钮 → 展开详情:
- 逐条列出每个冲突安装的路径、类型、版本
- 根据安装类型给出卸载命令(带一键复制按钮):
- npm: `npm uninstall -g @anthropic-ai/claude-code`
- bun: `bun remove -g @anthropic-ai/claude-code`
- homebrew: `brew uninstall --cask claude-code`
- 底部提示"清理完成后,点击重新检测以确认" + Re-detect 按钮
#### 3. 未找到(connected = false, missingGit = false)
- 显示安装命令:
- macOS/Linux: `curl -fsSL https://claude.ai/install.sh | bash`
- Windows: `irm https://claude.ai/install.ps1 | iex`
- Re-detect 按钮
#### 4. 缺少 Git(Windows 专用, missingGit = true)
- 显示 Git for Windows 安装步骤(三步引导)
- Re-detect 按钮
### 冲突处理的设计决策
- **不自动切换**:冲突状态下仍标记为 completed(能正常工作),只给出警告和清理建议
- **复用 InstallWizard 的卸载逻辑**:`getUninstallCommand()` 与 InstallWizard 的 `getUninstallAdvice()` 保持一致
- **CopyableCommand 组件**:卸载命令支持一键复制,降低操作门槛
## Provider 检测(详细)
### 三条凭据来源(优先级从高到低)
1. **DB provider**:`api_providers` 表中配置的自定义服务商(含 preset 预设)
2. **进程环境变量**:`ANTHROPIC_API_KEY` 或 `ANTHROPIC_AUTH_TOKEN`
3. **App settings token**:`getSetting('anthropic_auth_token')`(旧版本的凭据存储路径)
### 关键判断规则
- **`ANTHROPIC_BASE_URL` 不算凭据**:只有 `ANTHROPIC_API_KEY` 和 `ANTHROPIC_AUTH_TOKEN` 才是真正的认证凭据,仅有 base URL 无法发起请求
- **`OPENAI_API_KEY` 不参与判断**:运行时 env 解析不支持它,setup 也不应把它算作可用 provider
- **`skipped` 不等于 `completed`**:/chat 页只认 `completed`,跳过引导的用户在发消息前仍会看到"去配置 provider"的提示
- **skipped 不是粘性状态**:`/api/setup` 先查真实凭据,有任何一条就返回 `completed`;只有完全没有凭据时才看 `setup_provider_skipped` flag
### ProviderCard 交互
- 检测到 env 凭据 → 显示"使用 Claude Code 环境"按钮,一键确认
- 无凭据 → 显示"添加服务商"按钮,跳转 `/settings#providers`(hash 路由)
- 监听 `provider-changed` 事件自动刷新状态
## 项目目录
### 目录回退链
```
page.tsx 初始化 workingDir:
1. localStorage['codepilot:last-working-directory']
↓ 校验 /api/files/browse → 有效则使用
↓ 无效 → 清除 localStorage
2. GET /api/setup → defaultProject
↓ 校验 /api/files/browse → 有效则使用并写入 localStorage
3. 都无效 → 显示空状态(ChatEmptyState)
```
ChatListPanel 的 `handleNewChat` 使用相同的回退链:最近目录 → defaultProject → 弹 picker。
### 跨组件同步
- ProjectDirCard 选中目录后,通过 `project-directory-changed` CustomEvent 通知 /chat 页
- /chat 页监听该事件,实时更新 `workingDir` 状态
- 同时写入 `localStorage` 和 `/api/setup`(server 端持久化)
## Toast 系统
### 架构
- `useToast.ts`:全局状态 + `showToast()` 命令式 API
- `toast.tsx`:`<Toaster />` 组件,渲染在 AppShell 底层
- FIFO 策略,最多 3 条,默认 5s 消失(error 8s)
- 支持 `action: { label, onClick }` 可选操作按钮
### 使用场景
| 场景 | 类型 | 位置 |
|---|---|---|
| Push 成功/失败 | success/error | GitStatusSection |
| 标题保存失败 | error | UnifiedTopBar |
| 目录失效 | warning + action | ChatListPanel |
## Windows 适配
- UnifiedTopBar 右侧添加 138px spacer(3 × 46px 系统标题按钮宽度),通过 `useClientPlatform().isWindows` 判断
- 路径分割统一使用 `split(/[\\/]/).filter(Boolean)` 兼容反斜杠
- ProjectGroupHeader 的"在文件管理器中打开"文案根据平台动态切换(Finder / Explorer / Files)
## 文件清单
### 新增文件
| 文件 | 用途 |
|---|---|
| `src/hooks/useClientPlatform.ts` | SSR 安全的平台检测 hook |
| `src/hooks/useToast.ts` | Toast 状态管理 + 全局 API |
| `src/components/ui/toast.tsx` | Toaster 渲染组件 |
| `src/components/ui/error-banner.tsx` | 内联错误条 |
| `src/app/api/setup/route.ts` | Setup 状态 CRUD |
| `src/app/api/setup/recent-projects/route.ts` | 最近项目列表 |
| `src/components/setup/SetupCenter.tsx` | 引导蒙层主组件 |
| `src/components/setup/SetupCard.tsx` | 可复用卡片壳 |
| `src/components/setup/WelcomeCard.tsx` | 欢迎卡片 |
| `src/components/setup/ClaudeCodeCard.tsx` | Claude Code 检测 + 冲突处理 |
| `src/components/setup/ProviderCard.tsx` | Provider 检测 + 配置入口 |
| `src/components/setup/ProjectDirCard.tsx` | 项目目录选择 |
| `src/components/chat/ChatEmptyState.tsx` | /chat 空状态引导 |
### 关键修改文件
| 文件 | 改动 |
|---|---|
| `src/components/layout/AppShell.tsx` | 挂载 SetupCenter + Toaster |
| `src/components/layout/UnifiedTopBar.tsx` | Windows safe zone、路径兼容、移除 commit/push 按钮 |
| `src/components/git/GitStatusSection.tsx` | 新增 Commit + Push 按钮 |
| `src/components/layout/ConnectionStatus.tsx` | 断连时派发 setup-center 事件 |
| `src/components/settings/GeneralSection.tsx` | 首次设置引导入口 |
| `src/app/chat/page.tsx` | 目录校验回退链 + provider 检测 + ChatEmptyState |
| `src/components/layout/ChatListPanel.tsx` | 失效目录回退到 defaultProject |
| `src/i18n/en.ts` / `zh.ts` | ~80 个新 i18n 键 |
## 已知限制
- Setup Center 不是分步向导,而是所有卡片同时展示在滚动面板中
- Claude Code 冲突检测依赖 `findAllClaudeBinaries()` 的路径扫描,可能遗漏非标准安装路径
- Provider 检测不覆盖 `OPENAI_API_KEY`(运行时 env 模式不支持),如果未来支持需要同步更新检测逻辑
- 目录校验使用 `/api/files/browse` 接口,额外产生一次文件系统访问
+1
View File
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
electron: process.versions.electron,
node: process.versions.node,
chrome: process.versions.chrome,
platform: process.platform,
},
shell: {
openPath: (folderPath: string) => ipcRenderer.invoke('shell:open-path', folderPath),
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import fs from 'fs';
export async function GET() {
try {
const db = getDb();
const rows = db.prepare(
`SELECT DISTINCT working_directory FROM chat_sessions
WHERE working_directory IS NOT NULL AND working_directory != ''
ORDER BY updated_at DESC LIMIT 10`
).all() as { working_directory: string }[];
// Filter to only existing directories
const projects: string[] = [];
for (const row of rows) {
try {
const stat = fs.statSync(row.working_directory);
if (stat.isDirectory()) {
projects.push(row.working_directory);
}
} catch {
// Skip non-existent directories
}
}
return NextResponse.json({ projects });
} catch {
return NextResponse.json({ projects: [] });
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting, setSetting, getDb } from '@/lib/db';
import { findClaudeBinary } from '@/lib/platform';
export async function GET() {
try {
// Check if setup was already completed
const completedRaw = getSetting('setup_completed');
const completed = completedRaw === 'true';
// Claude status
let claude: 'not-configured' | 'completed' | 'skipped' | 'needs-fix' = 'not-configured';
const claudeSkipped = getSetting('setup_claude_skipped');
if (claudeSkipped === 'true') {
claude = 'skipped';
} else {
try {
const binary = findClaudeBinary();
claude = binary ? 'completed' : 'not-configured';
} catch {
claude = 'not-configured';
}
}
// Provider status — always check real credentials first, skipped is only a fallback
let provider: 'not-configured' | 'completed' | 'skipped' | 'needs-fix' = 'not-configured';
const db = getDb();
const row = db.prepare('SELECT COUNT(*) as cnt FROM api_providers').get() as { cnt: number } | undefined;
if (row && row.cnt > 0) {
provider = 'completed';
} else {
// Check env-based or app-settings-based credentials
const appToken = getSetting('anthropic_auth_token');
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || appToken) {
provider = 'completed';
} else {
// No real provider found — check if user previously skipped setup
const providerSkipped = getSetting('setup_provider_skipped');
provider = providerSkipped === 'true' ? 'skipped' : 'not-configured';
}
}
// Project status
let project: 'not-configured' | 'completed' | 'skipped' | 'needs-fix' = 'not-configured';
const projectSkipped = getSetting('setup_project_skipped');
const defaultProject = getSetting('setup_default_project');
if (projectSkipped === 'true') {
project = 'skipped';
} else if (defaultProject) {
// Validate path exists
const fs = await import('fs');
try {
const stat = fs.statSync(defaultProject);
project = stat.isDirectory() ? 'completed' : 'needs-fix';
} catch {
project = 'needs-fix';
}
}
return NextResponse.json({
completed,
claude,
provider,
project,
defaultProject: defaultProject || undefined,
});
} catch {
return NextResponse.json({ error: 'Failed to get setup state' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { card, status, value } = body;
if (!card || !status) {
return NextResponse.json({ error: 'Missing card or status' }, { status: 400 });
}
switch (card) {
case 'claude':
if (status === 'skipped') setSetting('setup_claude_skipped', 'true');
else if (status === 'completed') setSetting('setup_claude_skipped', '');
break;
case 'provider':
if (status === 'skipped') setSetting('setup_provider_skipped', 'true');
else if (status === 'completed') setSetting('setup_provider_skipped', '');
break;
case 'project':
if (status === 'skipped') {
setSetting('setup_project_skipped', 'true');
} else if (status === 'completed' && value) {
setSetting('setup_default_project', value);
setSetting('setup_project_skipped', '');
}
break;
case 'completed':
setSetting('setup_completed', 'true');
break;
default:
return NextResponse.json({ error: 'Unknown card' }, { status: 400 });
}
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Failed to update setup state' }, { status: 500 });
}
}
+163 -30
View File
@@ -9,6 +9,11 @@ import { ChatComposerActionBar } from '@/components/chat/ChatComposerActionBar';
import { ChatPermissionSelector } from '@/components/chat/ChatPermissionSelector';
import { ImageGenToggle } from '@/components/chat/ImageGenToggle';
import { PermissionPrompt } from '@/components/chat/PermissionPrompt';
import { ChatEmptyState } from '@/components/chat/ChatEmptyState';
import { ErrorBanner } from '@/components/ui/error-banner';
import { FolderPicker } from '@/components/chat/FolderPicker';
import { useNativeFolderPicker } from '@/hooks/useNativeFolderPicker';
import { useTranslation } from '@/hooks/useTranslation';
import { usePanel } from '@/hooks/usePanel';
interface ToolUseInfo {
@@ -25,13 +30,19 @@ interface ToolResultInfo {
export default function NewChatPage() {
const router = useRouter();
const { setPendingApprovalSessionId } = usePanel();
const { t } = useTranslation();
const { isElectron, openNativePicker } = useNativeFolderPicker();
const [messages, setMessages] = useState<Message[]>([]);
const [streamingContent, setStreamingContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [toolUses, setToolUses] = useState<ToolUseInfo[]>([]);
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
const [statusText, setStatusText] = useState<string | undefined>();
const [workingDir] = useState('');
const [workingDir, setWorkingDir] = useState('');
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [errorBanner, setErrorBanner] = useState<{ message: string; description?: string } | null>(null);
const [recentProjects, setRecentProjects] = useState<string[]>([]);
const [hasProvider, setHasProvider] = useState(true); // assume true until checked
const [mode] = useState('code');
const [currentModel, setCurrentModel] = useState('sonnet');
const [currentProviderId, setCurrentProviderId] = useState('');
@@ -63,6 +74,109 @@ export default function NewChatPage() {
return () => controller.abort();
}, [currentProviderId]);
// Initialize workingDir from localStorage (or setup default), validating the path exists
useEffect(() => {
let cancelled = false;
const validateDir = async (path: string): Promise<boolean> => {
try {
const res = await fetch(`/api/files/browse?dir=${encodeURIComponent(path)}`);
return res.ok;
} catch {
return false;
}
};
const tryFallbackToDefault = async () => {
try {
const res = await fetch('/api/setup');
if (!res.ok || cancelled) return;
const data = await res.json();
if (cancelled || !data?.defaultProject) return;
if (await validateDir(data.defaultProject) && !cancelled) {
setWorkingDir(data.defaultProject);
localStorage.setItem('codepilot:last-working-directory', data.defaultProject);
}
} catch { /* ignore */ }
};
const init = async () => {
const saved = localStorage.getItem('codepilot:last-working-directory');
if (saved) {
if (await validateDir(saved) && !cancelled) {
setWorkingDir(saved);
} else if (!cancelled) {
// Stale — clear and try setup default
localStorage.removeItem('codepilot:last-working-directory');
await tryFallbackToDefault();
}
} else {
await tryFallbackToDefault();
}
};
init();
const handler = (e: Event) => {
const path = (e as CustomEvent).detail?.path;
if (path) setWorkingDir(path);
};
window.addEventListener('project-directory-changed', handler);
return () => {
cancelled = true;
window.removeEventListener('project-directory-changed', handler);
};
}, []);
// Load recent projects for empty state
useEffect(() => {
fetch('/api/setup/recent-projects')
.then(r => r.ok ? r.json() : { projects: [] })
.then(data => setRecentProjects(data.projects || []))
.catch(() => {});
}, []);
// Check provider availability — only 'completed' counts, 'skipped' means user deferred but has no real credentials
useEffect(() => {
const checkProvider = () => {
fetch('/api/setup')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) {
setHasProvider(data.provider === 'completed');
}
})
.catch(() => {});
};
checkProvider();
window.addEventListener('provider-changed', checkProvider);
return () => window.removeEventListener('provider-changed', checkProvider);
}, []);
const handleSelectFolder = useCallback(async () => {
if (isElectron) {
const path = await openNativePicker({ title: t('folderPicker.title') });
if (path) {
setWorkingDir(path);
localStorage.setItem('codepilot:last-working-directory', path);
}
} else {
setFolderPickerOpen(true);
}
}, [isElectron, openNativePicker, t]);
const handleFolderPickerSelect = useCallback((path: string) => {
setWorkingDir(path);
localStorage.setItem('codepilot:last-working-directory', path);
setFolderPickerOpen(false);
}, []);
const handleSelectProject = useCallback((path: string) => {
setWorkingDir(path);
localStorage.setItem('codepilot:last-working-directory', path);
}, []);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
@@ -109,15 +223,16 @@ export default function NewChatPage() {
// Require a project directory before sending
if (!workingDir.trim()) {
const hint: Message = {
id: 'hint-' + Date.now(),
session_id: '',
role: 'assistant',
content: '**Please select a project directory first.** Use the folder picker in the toolbar below to choose a working directory before sending a message.',
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, hint]);
setErrorBanner({ message: t('chat.empty.noDirectory') });
return;
}
// Require a provider before sending
if (!hasProvider) {
setErrorBanner({
message: t('error.providerUnavailable'),
description: t('chat.empty.noProvider'),
});
return;
}
@@ -336,15 +451,7 @@ export default function NewChatPage() {
}
} else {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
const errorMessage: Message = {
id: 'temp-error-' + Date.now(),
session_id: '',
role: 'assistant',
content: `**Error:** ${errMsg}`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, errorMessage]);
setErrorBanner({ message: t('error.sessionCreateFailed'), description: errMsg });
}
} finally {
setIsStreaming(false);
@@ -359,7 +466,7 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
},
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, setPendingApprovalSessionId]
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, hasProvider]
);
const handleCommand = useCallback((command: string) => {
@@ -398,16 +505,37 @@ export default function NewChatPage() {
return (
<div className="flex h-full min-h-0 flex-col">
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
sessionId={createdSessionId}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
/>
{messages.length === 0 && !isStreaming && (!workingDir.trim() || !hasProvider) ? (
<ChatEmptyState
hasDirectory={!!workingDir.trim()}
hasProvider={hasProvider}
onSelectFolder={handleSelectFolder}
recentProjects={recentProjects}
onSelectProject={handleSelectProject}
/>
) : (
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
sessionId={createdSessionId}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
/>
)}
{errorBanner && (
<ErrorBanner
message={errorBanner.message}
description={errorBanner.description}
className="mx-4 mb-2"
onDismiss={() => setErrorBanner(null)}
actions={[
{ label: t('error.retry'), onClick: () => setErrorBanner(null) },
]}
/>
)}
<PermissionPrompt
pendingPermission={pendingPermission}
permissionResolved={permissionResolved}
@@ -440,6 +568,11 @@ export default function NewChatPage() {
/>
}
/>
<FolderPicker
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
onSelect={handleFolderPickerSelect}
/>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
'use client';
import { Button } from '@/components/ui/button';
import { FolderOpen } from '@/components/ui/icon';
import { useTranslation } from '@/hooks/useTranslation';
interface ChatEmptyStateProps {
hasDirectory: boolean;
hasProvider: boolean;
onSelectFolder: () => void;
recentProjects?: string[];
onSelectProject?: (path: string) => void;
}
export function ChatEmptyState({
hasDirectory,
hasProvider,
onSelectFolder,
recentProjects,
onSelectProject,
}: ChatEmptyStateProps) {
const { t } = useTranslation();
if (hasDirectory && hasProvider) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-sm text-muted-foreground">{t('chat.empty.ready')}</p>
</div>
);
}
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-sm space-y-4 text-center">
{!hasDirectory && (
<div className="space-y-2">
<p className="text-sm font-medium">{t('chat.empty.noDirectory')}</p>
<div className="flex flex-col items-center gap-2">
<Button size="sm" className="gap-1.5" onClick={onSelectFolder}>
<FolderOpen size={14} />
{t('chat.empty.selectFolder')}
</Button>
{recentProjects && recentProjects.length > 0 && onSelectProject && (
<div className="space-y-1.5 mt-2">
<p className="text-xs text-muted-foreground">{t('chat.empty.recentProjects')}</p>
<div className="flex flex-wrap justify-center gap-1.5">
{recentProjects.slice(0, 5).map(p => {
const name = p.split(/[\\/]/).filter(Boolean).pop() || p;
return (
<Button
key={p}
variant="outline"
size="sm"
className="h-6 px-2 text-[11px] font-mono"
onClick={() => onSelectProject(p)}
title={p}
>
{name}
</Button>
);
})}
</div>
</div>
)}
</div>
</div>
)}
{!hasProvider && (
<div className="space-y-2">
<p className="text-sm font-medium">{t('chat.empty.noProvider')}</p>
<Button
size="sm"
variant="outline"
onClick={() => window.dispatchEvent(new CustomEvent('open-setup-center', { detail: { initialCard: 'provider' } }))}
>
{t('chat.empty.openSetup')}
</Button>
</div>
)}
</div>
</div>
);
}
+67 -1
View File
@@ -1,7 +1,12 @@
"use client";
import { GitBranch, ArrowUp, ArrowLeft, Circle } from "@/components/ui/icon";
import { useState, useCallback } from "react";
import { GitBranch, GitCommit, CloudArrowUp, ArrowUp, ArrowLeft, Circle } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/hooks/useTranslation";
import { usePanel } from "@/hooks/usePanel";
import { showToast } from "@/hooks/useToast";
import { CommitDialog } from "./CommitDialog";
import type { GitStatus, GitChangedFile } from "@/types";
interface GitStatusSectionProps {
@@ -10,6 +15,36 @@ interface GitStatusSectionProps {
export function GitStatusSection({ status }: GitStatusSectionProps) {
const { t } = useTranslation();
const { workingDirectory } = usePanel();
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
const [pushing, setPushing] = useState(false);
const handlePush = useCallback(async () => {
if (!workingDirectory || pushing) return;
setPushing(true);
try {
const res = await fetch('/api/git/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cwd: workingDirectory }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Push failed' }));
showToast({ type: 'error', message: data.error || 'Push failed' });
return;
}
showToast({ type: 'success', message: t('git.pushSuccess') });
window.dispatchEvent(new CustomEvent('git-refresh'));
} catch (err) {
showToast({ type: 'error', message: err instanceof Error ? err.message : 'Push failed' });
} finally {
setPushing(false);
}
}, [workingDirectory, pushing, t]);
const handleCommitSuccess = useCallback(() => {
window.dispatchEvent(new CustomEvent('git-refresh'));
}, []);
if (!status.isRepo) {
return (
@@ -92,6 +127,37 @@ export function GitStatusSection({ status }: GitStatusSectionProps) {
</div>
);
})()}
{/* Commit & Push actions */}
<div className="flex items-center gap-2 px-3 pt-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1.5 flex-1"
onClick={() => setCommitDialogOpen(true)}
disabled={!status.dirty}
>
<GitCommit size={14} />
{t('topBar.commit')}
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1.5 flex-1"
onClick={handlePush}
disabled={pushing}
>
<CloudArrowUp size={14} />
{pushing ? t('git.loading') : t('topBar.push')}
</Button>
</div>
<CommitDialog
cwd={workingDirectory}
open={commitDialogOpen}
onClose={() => setCommitDialogOpen(false)}
onSuccess={handleCommitSuccess}
/>
</div>
);
}
+34
View File
@@ -20,6 +20,8 @@ import { SplitChatContainer } from "./SplitChatContainer";
import { ErrorBoundary } from "./ErrorBoundary";
import { getActiveSessionIds, getSnapshot } from "@/lib/stream-session-manager";
import { useGitStatus } from "@/hooks/useGitStatus";
import { SetupCenter } from '@/components/setup/SetupCenter';
import { Toaster } from '@/components/ui/toast';
const SPLIT_SESSIONS_KEY = "codepilot:split-sessions";
const SPLIT_ACTIVE_COLUMN_KEY = "codepilot:split-active-column";
@@ -69,6 +71,31 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [chatListOpenRaw, setChatListOpenRaw] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupInitialCard, setSetupInitialCard] = useState<'claude' | 'provider' | 'project' | undefined>();
// Check if setup is needed
useEffect(() => {
fetch('/api/setup')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && !data.completed) {
setSetupOpen(true);
}
})
.catch(() => {});
}, []);
// Listen for open-setup-center events
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setSetupInitialCard(detail?.initialCard);
setSetupOpen(true);
};
window.addEventListener('open-setup-center', handler);
return () => window.removeEventListener('open-setup-center', handler);
}, []);
// Sync with viewport after hydration to avoid SSR mismatch
/* eslint-disable react-hooks/set-state-in-effect */
@@ -420,6 +447,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
</div>
<UpdateDialog />
<Toaster />
{setupOpen && (
<SetupCenter
onClose={() => setSetupOpen(false)}
initialCard={setupInitialCard}
/>
)}
</TooltipProvider>
</BatchImageGenContext.Provider>
</ImageGenContext.Provider>
+42 -5
View File
@@ -20,6 +20,7 @@ import { usePanel } from "@/hooks/usePanel";
import { useSplit } from "@/hooks/useSplit";
import { useTranslation } from "@/hooks/useTranslation";
import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker";
import { showToast } from '@/hooks/useToast';
import { ConnectionStatus } from "./ConnectionStatus";
import { ImportSessionDialog } from "./ImportSessionDialog";
import { SessionListItem, SplitGroupSection } from "./SessionListItem";
@@ -96,9 +97,23 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
}, [isElectron, openNativePicker, t, handleFolderSelect]);
const handleNewChat = useCallback(async () => {
const lastDir = workingDirectory
let lastDir = workingDirectory
|| (typeof window !== 'undefined' ? localStorage.getItem("codepilot:last-working-directory") : null);
// Fall back to setup default project if no recent directory
if (!lastDir) {
try {
const setupRes = await fetch('/api/setup');
if (setupRes.ok) {
const setupData = await setupRes.json();
if (setupData.defaultProject) {
lastDir = setupData.defaultProject;
localStorage.setItem('codepilot:last-working-directory', lastDir!);
}
}
} catch { /* ignore */ }
}
if (!lastDir) {
// No saved directory — let user pick one
openFolderPicker();
@@ -112,10 +127,32 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
`/api/files/browse?dir=${encodeURIComponent(lastDir)}`
);
if (!checkRes.ok) {
// Directory is gone — clear stale value and prompt user
// Directory is gone — clear stale value, try setup default before prompting
localStorage.removeItem("codepilot:last-working-directory");
openFolderPicker();
return;
let recovered = false;
try {
const setupRes = await fetch('/api/setup');
if (setupRes.ok) {
const setupData = await setupRes.json();
if (setupData.defaultProject && setupData.defaultProject !== lastDir) {
const defaultCheck = await fetch(`/api/files/browse?dir=${encodeURIComponent(setupData.defaultProject)}`);
if (defaultCheck.ok) {
lastDir = setupData.defaultProject;
localStorage.setItem('codepilot:last-working-directory', lastDir!);
recovered = true;
}
}
}
} catch { /* ignore */ }
if (!recovered) {
showToast({
type: 'warning',
message: t('error.directoryInvalid'),
action: { label: t('error.selectDirectory'), onClick: () => openFolderPicker() },
});
openFolderPicker();
return;
}
}
const { model, provider_id } = getCurrentModelAndProvider();
@@ -138,7 +175,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
} finally {
setCreatingChat(false);
}
}, [router, workingDirectory, openFolderPicker, getCurrentModelAndProvider]);
}, [router, workingDirectory, openFolderPicker, getCurrentModelAndProvider, t]);
const toggleProject = useCallback((wd: string) => {
setCollapsedProjects((prev) => {
+5 -6
View File
@@ -127,23 +127,22 @@ export function ConnectionStatus() {
checkStatus();
}, [checkStatus]);
// Auto-prompt install wizard on first disconnect detection (Electron only)
// Auto-prompt setup center on first disconnect detection (instead of install wizard)
useEffect(() => {
if (
status !== null &&
!status.connected &&
isElectron &&
!autoPromptedRef.current &&
!dialogOpen &&
!wizardOpen
!dialogOpen
) {
const dismissed = localStorage.getItem("codepilot:install-wizard-dismissed");
if (!dismissed) {
autoPromptedRef.current = true;
setWizardOpen(true); // eslint-disable-line react-hooks/set-state-in-effect -- intentional: auto-prompt on first disconnect
window.dispatchEvent(new CustomEvent('open-setup-center', { detail: { initialCard: 'claude' } }));
localStorage.setItem("codepilot:install-wizard-dismissed", "1");
}
}
}, [status, isElectron, dialogOpen, wizardOpen]);
}, [status, dialogOpen]);
const handleWizardOpenChange = useCallback((open: boolean) => {
setWizardOpen(open);
+9 -4
View File
@@ -15,6 +15,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslation } from '@/hooks/useTranslation';
import { useClientPlatform } from '@/hooks/useClientPlatform';
interface ProjectGroupHeaderProps {
workingDirectory: string;
@@ -39,6 +41,9 @@ export function ProjectGroupHeader({
onMouseLeave,
onCreateSession,
}: ProjectGroupHeaderProps) {
const { t } = useTranslation();
const { fileManagerName } = useClientPlatform();
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -97,20 +102,20 @@ export function ProjectGroupHeader({
>
<Plus size={14} />
<span className="sr-only">
New chat in {displayName}
{t('chatList.newConversation')} - {displayName}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
New chat in {displayName}
{t('chatList.newConversation')} - {displayName}
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs break-all">{workingDirectory || 'No Project'}</p>
{workingDirectory && <p className="text-[10px] text-muted-foreground mt-0.5">Double-click to open in Finder</p>}
<p className="text-xs break-all">{workingDirectory || t('chatList.noSessions')}</p>
{workingDirectory && <p className="text-[10px] text-muted-foreground mt-0.5">{t('platform.openInFileManager', { fileManager: fileManagerName })}</p>}
</TooltipContent>
</Tooltip>
);
+7 -102
View File
@@ -4,12 +4,9 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import {
GitBranch,
GitCommit,
CloudArrowUp,
TreeStructure,
PencilSimple,
DotOutline,
CaretDown,
} from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,15 +15,10 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { usePanel } from "@/hooks/usePanel";
import { useTranslation } from "@/hooks/useTranslation";
import { CommitDialog } from "@/components/git/CommitDialog";
import { useClientPlatform } from '@/hooks/useClientPlatform';
import { showToast } from '@/hooks/useToast';
export function UnifiedTopBar() {
const {
@@ -42,6 +34,7 @@ export function UnifiedTopBar() {
gitDirtyCount,
} = usePanel();
const { t } = useTranslation();
const { isWindows } = useClientPlatform();
const pathname = usePathname();
// Only show Git/terminal/panel controls on chat detail routes (/chat/[id]),
@@ -75,10 +68,10 @@ export function UnifiedTopBar() {
window.dispatchEvent(new CustomEvent('session-updated', { detail: { id: sessionId, title: trimmed } }));
}
} catch {
// silently fail
showToast({ type: 'error', message: t('error.titleSaveFailed') });
}
setIsEditingTitle(false);
}, [editTitle, sessionId, setSessionTitle]);
}, [editTitle, sessionId, setSessionTitle, t]);
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
@@ -95,50 +88,8 @@ export function UnifiedTopBar() {
}
}, [isEditingTitle]);
// --- Commit dialog ---
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
const handleCommitSuccess = useCallback(() => {
// Trigger git status refresh via custom event
window.dispatchEvent(new CustomEvent('git-refresh'));
}, []);
// --- Push handler ---
const [pushing, setPushing] = useState(false);
const [pushMessage, setPushMessage] = useState<{ type: 'error' | 'success'; text: string } | null>(null);
const handlePush = useCallback(async () => {
if (!workingDirectory || pushing) return;
setPushing(true);
setPushMessage(null);
try {
const res = await fetch('/api/git/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cwd: workingDirectory }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Push failed' }));
setPushMessage({ type: 'error', text: data.error || 'Push failed' });
return;
}
setPushMessage({ type: 'success', text: t('git.pushSuccess') });
window.dispatchEvent(new CustomEvent('git-refresh'));
} catch (err) {
setPushMessage({ type: 'error', text: err instanceof Error ? err.message : 'Push failed' });
} finally {
setPushing(false);
}
}, [workingDirectory, pushing, t]);
// Auto-dismiss push message after 4s
useEffect(() => {
if (!pushMessage) return;
const timer = setTimeout(() => setPushMessage(null), 4000);
return () => clearTimeout(timer);
}, [pushMessage]);
// Extract project name from working directory
const projectName = workingDirectory ? workingDirectory.split('/').pop() || '' : '';
const projectName = workingDirectory ? workingDirectory.split(/[\\/]/).filter(Boolean).pop() || '' : '';
return (
<>
@@ -225,45 +176,6 @@ export function UnifiedTopBar() {
>
{isChatRoute && (
<>
{/* Commit button + Push dropdown */}
<div className="flex items-center border border-border rounded-md">
<Button
variant="ghost"
size="sm"
onClick={() => setCommitDialogOpen(true)}
className="rounded-r-none h-7 px-2 text-xs gap-1"
>
<GitCommit size={14} />
{t('topBar.commit')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="rounded-l-none border-l border-border h-7 px-1 min-w-0"
>
<CaretDown size={10} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem onClick={() => setCommitDialogOpen(true)}>
<GitCommit size={14} className="mr-2" />
{t('topBar.commit')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePush} disabled={pushing}>
<CloudArrowUp size={14} className="mr-2" />
{pushing ? t('git.loading') : t('topBar.push')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{pushMessage && (
<span className={`text-xs px-1.5 py-0.5 rounded ${pushMessage.type === 'error' ? 'text-destructive' : 'text-emerald-600'}`}>
{pushMessage.text}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -304,16 +216,9 @@ export function UnifiedTopBar() {
</Tooltip>
</>
)}
{isWindows && <div style={{ width: 138 }} className="shrink-0" />}
</div>
</div>
{/* Commit Dialog */}
<CommitDialog
cwd={workingDirectory}
open={commitDialogOpen}
onClose={() => setCommitDialogOpen(false)}
onSuccess={handleCommitSuccess}
/>
</>
);
}
@@ -217,6 +217,22 @@ export function GeneralSection() {
</Select>
</FieldRow>
{/* Setup Center */}
<FieldRow
label={t('setup.openSetupCenter')}
description={t('setup.openSetupCenterDesc')}
separator
>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => window.dispatchEvent(new CustomEvent('open-setup-center'))}
>
{t('setup.open')}
</Button>
</FieldRow>
</SettingsCard>
{/* Appearance */}
+193
View File
@@ -0,0 +1,193 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Copy, Check } from '@/components/ui/icon';
import { SetupCard } from './SetupCard';
import { useTranslation } from '@/hooks/useTranslation';
import type { SetupCardStatus } from '@/types';
const INSTALL_TYPE_LABELS: Record<string, string> = {
native: 'Native',
npm: 'npm',
bun: 'Bun',
homebrew: 'Homebrew',
unknown: 'Unknown',
};
function getUninstallCommand(type: string): string | null {
switch (type) {
case 'npm': return 'npm uninstall -g @anthropic-ai/claude-code';
case 'bun': return 'bun remove -g @anthropic-ai/claude-code';
case 'homebrew': return 'brew uninstall --cask claude-code';
default: return null;
}
}
interface ClaudeStatus {
connected: boolean;
version: string | null;
binaryPath?: string | null;
installType?: string | null;
otherInstalls?: Array<{ path: string; version: string | null; type: string }>;
missingGit?: boolean;
}
interface ClaudeCodeCardProps {
status: SetupCardStatus;
onStatusChange: (status: SetupCardStatus) => void;
}
function CopyableCommand({ command }: { command: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="flex items-center gap-1 mt-0.5">
<code className="block rounded bg-muted px-2 py-1 text-[10px] flex-1 select-all">{command}</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</div>
);
}
export function ClaudeCodeCard({ status, onStatusChange }: ClaudeCodeCardProps) {
const { t } = useTranslation();
const [claudeStatus, setClaudeStatus] = useState<ClaudeStatus | null>(null);
const [checking, setChecking] = useState(true);
const [showCleanup, setShowCleanup] = useState(false);
const checkStatus = useCallback(async () => {
setChecking(true);
try {
const res = await fetch('/api/claude-status');
if (res.ok) {
const data: ClaudeStatus = await res.json();
setClaudeStatus(data);
if (data.connected) {
onStatusChange('completed');
}
}
} catch {
// ignore
} finally {
setChecking(false);
}
}, [onStatusChange]);
useEffect(() => {
checkStatus();
}, [checkStatus]);
const handleSkip = useCallback(async () => {
onStatusChange('skipped');
try {
await fetch('/api/setup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card: 'claude', status: 'skipped' }),
});
} catch { /* ignore */ }
}, [onStatusChange]);
const description = claudeStatus?.connected
? `${t('setup.claude.detected')} — v${claudeStatus.version}`
: claudeStatus?.missingGit
? t('setup.claude.missingGit')
: t('setup.claude.description');
return (
<SetupCard
title={t('setup.claude.title')}
description={description}
status={status}
onSkip={status === 'not-configured' || status === 'needs-fix' ? handleSkip : undefined}
>
{checking ? (
<p className="text-xs text-muted-foreground">Checking...</p>
) : claudeStatus?.connected ? (
<div className="space-y-1">
<p className="text-xs">v{claudeStatus.version} ({claudeStatus.installType})</p>
{claudeStatus.binaryPath && (
<p className="text-[10px] font-mono text-muted-foreground/60">{claudeStatus.binaryPath}</p>
)}
{(claudeStatus.otherInstalls?.length ?? 0) > 0 && (
<div className="mt-2 rounded bg-status-warning-muted p-2.5 text-xs space-y-2">
<p className="font-medium text-status-warning-foreground">{t('setup.claude.conflict')}</p>
<div className="text-muted-foreground">
<p className="font-medium text-foreground">{t('setup.claude.conflictUsing')}:</p>
<p className="mt-0.5">
<code className="bg-muted px-1 rounded text-[10px]">{claudeStatus.binaryPath}</code>
{' '}({INSTALL_TYPE_LABELS[claudeStatus.installType || 'unknown']} v{claudeStatus.version})
</p>
</div>
{!showCleanup ? (
<Button size="sm" variant="outline" className="text-xs h-7" onClick={() => setShowCleanup(true)}>
{t('setup.claude.viewCleanup')}
</Button>
) : (
<div className="space-y-2 border-t border-border/50 pt-2">
{claudeStatus.otherInstalls?.map((inst, i) => {
const cmd = getUninstallCommand(inst.type);
return (
<div key={i} className="space-y-0.5">
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{t('setup.claude.conflictOther')}:</span>{' '}
<code className="bg-muted px-1 rounded text-[10px]">{inst.path}</code>
{' '}({INSTALL_TYPE_LABELS[inst.type]} {inst.version && `v${inst.version}`})
</p>
{cmd && (
<div>
<p className="text-muted-foreground/80 text-[11px]">{t('setup.claude.conflictRemoveHint')}</p>
<CopyableCommand command={cmd} />
</div>
)}
</div>
);
})}
<p className="text-muted-foreground/80 text-[11px] pt-1">{t('setup.claude.conflictResolved')}</p>
<Button size="sm" variant="outline" className="text-xs h-7" onClick={checkStatus}>
{t('setup.claude.redetect')}
</Button>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-2">
{claudeStatus?.missingGit ? (
<div className="text-xs text-muted-foreground space-y-1">
<p>{t('install.gitDescription')}</p>
<ol className="list-decimal list-inside space-y-0.5 text-[11px]">
<li>{t('install.gitStep1')}</li>
<li>{t('install.gitStep2')}</li>
<li>{t('install.gitStep3')}</li>
</ol>
</div>
) : (
<div className="text-xs text-muted-foreground">
<p>{t('setup.claude.notFound')}</p>
<code className="block rounded bg-muted px-2 py-1 mt-1 text-[11px]">
{typeof navigator !== 'undefined' && navigator.platform?.startsWith('Win')
? 'irm https://claude.ai/install.ps1 | iex'
: 'curl -fsSL https://claude.ai/install.sh | bash'}
</code>
</div>
)}
<Button size="sm" variant="outline" className="text-xs" onClick={checkStatus}>
{t('setup.claude.redetect')}
</Button>
</div>
)}
</SetupCard>
);
}
+128
View File
@@ -0,0 +1,128 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SetupCard } from './SetupCard';
import { FolderPicker } from '@/components/chat/FolderPicker';
import { useTranslation } from '@/hooks/useTranslation';
import { useNativeFolderPicker } from '@/hooks/useNativeFolderPicker';
import type { SetupCardStatus } from '@/types';
interface ProjectDirCardProps {
status: SetupCardStatus;
onStatusChange: (status: SetupCardStatus, value?: string) => void;
defaultProject?: string;
}
export function ProjectDirCard({ status, onStatusChange, defaultProject }: ProjectDirCardProps) {
const { t } = useTranslation();
const { isElectron, openNativePicker } = useNativeFolderPicker();
const [recentProjects, setRecentProjects] = useState<string[]>([]);
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [selectedPath, setSelectedPath] = useState<string>(defaultProject || '');
// Sync selectedPath when defaultProject prop arrives asynchronously
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (defaultProject && !selectedPath) {
setSelectedPath(defaultProject);
}
}, [defaultProject, selectedPath]);
/* eslint-enable react-hooks/set-state-in-effect */
useEffect(() => {
fetch('/api/setup/recent-projects')
.then(r => r.ok ? r.json() : { projects: [] })
.then(data => setRecentProjects(data.projects || []))
.catch(() => {});
}, []);
const handleSelect = useCallback(async (path: string) => {
setSelectedPath(path);
onStatusChange('completed', path);
// Persist
localStorage.setItem('codepilot:last-working-directory', path);
// Notify other components (e.g. /chat page) that a project directory was selected
window.dispatchEvent(new CustomEvent('project-directory-changed', { detail: { path } }));
try {
await fetch('/api/setup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card: 'project', status: 'completed', value: path }),
});
} catch { /* ignore */ }
}, [onStatusChange]);
const handleBrowse = useCallback(async () => {
if (isElectron) {
const path = await openNativePicker({ title: t('folderPicker.title') });
if (path) handleSelect(path);
} else {
setFolderPickerOpen(true);
}
}, [isElectron, openNativePicker, t, handleSelect]);
const handleSkip = useCallback(async () => {
onStatusChange('skipped');
try {
await fetch('/api/setup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card: 'project', status: 'skipped' }),
});
} catch { /* ignore */ }
}, [onStatusChange]);
const description = status === 'completed'
? `${t('setup.project.selected')}: ${selectedPath}`
: t('setup.project.description');
return (
<>
<SetupCard
title={t('setup.project.title')}
description={description}
status={status}
onSkip={status === 'not-configured' ? handleSkip : undefined}
>
{status === 'completed' ? (
<p className="text-xs font-mono truncate">{selectedPath}</p>
) : (
<div className="space-y-3">
{recentProjects.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1.5">{t('setup.project.recentProjects')}</p>
<div className="flex flex-wrap gap-1.5">
{recentProjects.map(p => {
const name = p.split(/[\\/]/).filter(Boolean).pop() || p;
return (
<Button
key={p}
variant="outline"
size="sm"
className="h-6 px-2 text-[11px] font-mono"
onClick={() => handleSelect(p)}
title={p}
>
{name}
</Button>
);
})}
</div>
</div>
)}
<Button size="sm" className="text-xs" onClick={handleBrowse}>
{t('setup.project.selectDirectory')}
</Button>
</div>
)}
</SetupCard>
<FolderPicker
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
onSelect={handleSelect}
/>
</>
);
}
+118
View File
@@ -0,0 +1,118 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SetupCard } from './SetupCard';
import { useTranslation } from '@/hooks/useTranslation';
import type { SetupCardStatus, ApiProvider } from '@/types';
interface ProviderCardProps {
status: SetupCardStatus;
onStatusChange: (status: SetupCardStatus) => void;
}
export function ProviderCard({ status, onStatusChange }: ProviderCardProps) {
const { t } = useTranslation();
const [providers, setProviders] = useState<ApiProvider[]>([]);
const [envDetected, setEnvDetected] = useState(false);
const [loading, setLoading] = useState(true);
const fetchProviders = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/providers');
if (res.ok) {
const data = await res.json();
setProviders(data.providers || []);
// Only count actual credential keys, not just ANTHROPIC_BASE_URL
const credentialKeys = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
let hasEnv = data.env_detected && credentialKeys.some(k => k in data.env_detected);
// Also check legacy app-settings token
if (!hasEnv) {
try {
const settingsRes = await fetch('/api/settings/app');
if (settingsRes.ok) {
const settingsData = await settingsRes.json();
const appToken = settingsData.settings?.anthropic_auth_token;
if (appToken && !appToken.startsWith('***not')) hasEnv = true;
}
} catch { /* ignore */ }
}
setEnvDetected(hasEnv);
if ((data.providers?.length ?? 0) > 0 || hasEnv) {
onStatusChange('completed');
}
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [onStatusChange]);
useEffect(() => {
fetchProviders();
const handler = () => fetchProviders();
window.addEventListener('provider-changed', handler);
return () => window.removeEventListener('provider-changed', handler);
}, [fetchProviders]);
const handleSkip = useCallback(async () => {
onStatusChange('skipped');
try {
await fetch('/api/setup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card: 'provider', status: 'skipped' }),
});
} catch { /* ignore */ }
}, [onStatusChange]);
const handleOpenProviders = useCallback(() => {
// Navigate to settings providers section (SettingsLayout uses hash routing)
window.location.href = '/settings#providers';
}, []);
const description = status === 'completed'
? t('setup.provider.configured')
: t('setup.provider.description');
return (
<SetupCard
title={t('setup.provider.title')}
description={description}
status={status}
onSkip={status === 'not-configured' ? handleSkip : undefined}
>
{loading ? (
<p className="text-xs text-muted-foreground">Loading...</p>
) : status === 'completed' ? (
<p className="text-xs">
{providers.length > 0
? `${providers.length} provider(s) configured`
: envDetected
? 'Using Claude Code environment'
: ''}
</p>
) : (
<div className="space-y-2">
{envDetected ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('setup.provider.envDetected')}</p>
<Button size="sm" className="text-xs" onClick={() => {
onStatusChange('completed');
}}>
{t('setup.provider.useEnv')}
</Button>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('setup.provider.noProvider')}</p>
<Button size="sm" className="text-xs" onClick={handleOpenProviders}>
{t('provider.addProvider')}
</Button>
</div>
)}
</div>
)}
</SetupCard>
);
}
+69
View File
@@ -0,0 +1,69 @@
'use client';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { CheckCircle, Minus, Warning, Circle } from '@/components/ui/icon';
import type { SetupCardStatus } from '@/types';
import { useTranslation } from '@/hooks/useTranslation';
const STATUS_CONFIG = {
'not-configured': { icon: Circle, color: 'text-muted-foreground', label: 'Not configured' },
'completed': { icon: CheckCircle, color: 'text-status-success', label: 'Completed' },
'skipped': { icon: Minus, color: 'text-muted-foreground/60', label: 'Skipped' },
'needs-fix': { icon: Warning, color: 'text-status-warning', label: 'Needs attention' },
};
interface SetupCardProps {
title: string;
description: string;
status: SetupCardStatus;
icon?: React.ReactNode;
onSkip?: () => void;
skipLabel?: string;
children: React.ReactNode;
}
export function SetupCard({ title, description, status, icon, onSkip, skipLabel, children }: SetupCardProps) {
const { t } = useTranslation();
const statusCfg = STATUS_CONFIG[status];
const StatusIcon = statusCfg.icon;
return (
<div className={cn(
'rounded-lg border bg-card p-4 space-y-3',
status === 'completed' && 'border-status-success/30',
status === 'needs-fix' && 'border-status-warning/30',
)}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
{icon && <div className="mt-0.5">{icon}</div>}
<div>
<h3 className="text-sm font-medium">{title}</h3>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<StatusIcon size={16} className={statusCfg.color} />
<span className={cn('text-xs', statusCfg.color)}>{statusCfg.label}</span>
</div>
</div>
{status !== 'completed' && status !== 'skipped' && (
<div className="space-y-3">
{children}
{onSkip && (
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground" onClick={onSkip}>
{skipLabel || t('setup.claude.skip')}
</Button>
)}
</div>
)}
{(status === 'completed' || status === 'skipped') && (
<div className="text-xs text-muted-foreground">
{children}
</div>
)}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { WelcomeCard } from './WelcomeCard';
import { ClaudeCodeCard } from './ClaudeCodeCard';
import { ProviderCard } from './ProviderCard';
import { ProjectDirCard } from './ProjectDirCard';
import { useTranslation } from '@/hooks/useTranslation';
import type { SetupCardStatus } from '@/types';
interface SetupCenterProps {
onClose: () => void;
initialCard?: 'claude' | 'provider' | 'project';
}
export function SetupCenter({ onClose, initialCard }: SetupCenterProps) {
const { t } = useTranslation();
const [claudeStatus, setClaudeStatus] = useState<SetupCardStatus>('not-configured');
const [providerStatus, setProviderStatus] = useState<SetupCardStatus>('not-configured');
const [projectStatus, setProjectStatus] = useState<SetupCardStatus>('not-configured');
const [defaultProject, setDefaultProject] = useState<string | undefined>();
// Track the initial completedCount from the server so we only auto-close
// when the user completes the last card during this session, not on reopen.
const initialCompletedCountRef = useRef<number | null>(null);
// Load initial status
useEffect(() => {
fetch('/api/setup')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) {
setClaudeStatus(data.claude);
setProviderStatus(data.provider);
setProjectStatus(data.project);
if (data.defaultProject) setDefaultProject(data.defaultProject);
// Record how many were already done when we opened
const initial = [data.claude, data.provider, data.project]
.filter((s: string) => s === 'completed' || s === 'skipped').length;
initialCompletedCountRef.current = initial;
}
})
.catch(() => {});
}, []);
const completedCount = [claudeStatus, providerStatus, projectStatus]
.filter(s => s === 'completed' || s === 'skipped').length;
// Auto-close when all done — but only if user made progress during this session
useEffect(() => {
if (
completedCount === 3 &&
initialCompletedCountRef.current !== null &&
initialCompletedCountRef.current < 3
) {
// Mark as completed
fetch('/api/setup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card: 'completed', status: 'completed' }),
}).catch(() => {});
// Brief delay before closing
const timer = setTimeout(onClose, 800);
return () => clearTimeout(timer);
}
}, [completedCount, onClose]);
// Scroll to initial card
useEffect(() => {
if (initialCard) {
const el = document.getElementById(`setup-card-${initialCard}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [initialCard]);
const handleProjectStatusChange = useCallback((status: SetupCardStatus, _value?: string) => {
setProjectStatus(status);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl border bg-card shadow-2xl">
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between bg-card px-6 pt-6 pb-3 border-b">
<div>
<h2 className="text-lg font-semibold">{t('setup.title')}</h2>
<p className="text-xs text-muted-foreground">{t('setup.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{t('setup.progress', { completed: String(completedCount) })}
</span>
<Button variant="ghost" size="sm" className="text-xs" onClick={() => {
// Persist skip so setup center doesn't reopen on next launch
fetch('/api/setup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card: 'completed', status: 'completed' }),
}).catch(() => {});
onClose();
}}>
{t('setup.skipAndEnter')}
</Button>
</div>
</div>
{/* Cards */}
<div className="p-6 space-y-4">
<WelcomeCard />
<div id="setup-card-claude">
<ClaudeCodeCard
status={claudeStatus}
onStatusChange={setClaudeStatus}
/>
</div>
<div id="setup-card-provider">
<ProviderCard
status={providerStatus}
onStatusChange={setProviderStatus}
/>
</div>
<div id="setup-card-project">
<ProjectDirCard
status={projectStatus}
onStatusChange={handleProjectStatusChange}
defaultProject={defaultProject}
/>
</div>
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { useTranslation } from '@/hooks/useTranslation';
export function WelcomeCard() {
const { t } = useTranslation();
const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
return (
<div className="rounded-lg border bg-card p-4 space-y-2">
<h3 className="text-sm font-medium">{t('setup.welcome.title')}</h3>
<p className="text-xs text-muted-foreground">{t('setup.welcome.description')}</p>
<p className="text-[10px] text-muted-foreground/50">v{version}</p>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { XCircle, X } from '@/components/ui/icon';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface ErrorBannerAction {
label: string;
onClick: () => void;
variant?: 'default' | 'outline' | 'ghost';
}
interface ErrorBannerProps {
message: string;
description?: string;
actions?: ErrorBannerAction[];
onDismiss?: () => void;
className?: string;
}
export function ErrorBanner({ message, description, actions, onDismiss, className }: ErrorBannerProps) {
return (
<div className={cn(
'flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm',
className
)}>
<XCircle size={16} className="shrink-0 text-destructive mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-destructive">{message}</p>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
{actions && actions.length > 0 && (
<div className="flex gap-2 mt-1.5">
{actions.map((action, i) => (
<Button
key={i}
variant={action.variant || 'outline'}
size="sm"
className="h-6 px-2 text-xs"
onClick={action.onClick}
>
{action.label}
</Button>
))}
</div>
)}
</div>
{onDismiss && (
<button onClick={onDismiss} className="shrink-0 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10">
<X size={12} />
</button>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { X, CheckCircle, XCircle, Warning, Info } from '@/components/ui/icon';
import { Button } from '@/components/ui/button';
import { useToastState, type Toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
const ICON_MAP = {
success: CheckCircle,
error: XCircle,
warning: Warning,
info: Info,
};
const STYLE_MAP = {
success: 'border-status-success/30 bg-status-success-muted text-status-success-foreground',
error: 'border-destructive/30 bg-destructive/10 text-destructive',
warning: 'border-status-warning/30 bg-status-warning-muted text-status-warning-foreground',
info: 'border-border bg-muted text-foreground',
};
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const Icon = ICON_MAP[toast.type];
return (
<div
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 shadow-lg text-sm animate-in slide-in-from-bottom-2 fade-in duration-200',
STYLE_MAP[toast.type]
)}
>
<Icon size={16} className="shrink-0" />
<span className="flex-1 min-w-0 truncate">{toast.message}</span>
{toast.action && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs shrink-0"
onClick={toast.action.onClick}
>
{toast.action.label}
</Button>
)}
<button onClick={onDismiss} className="shrink-0 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10">
<X size={12} />
</button>
</div>
);
}
export function Toaster() {
const { toasts, removeToast } = useToastState();
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
{toasts.map(toast => (
<ToastItem
key={toast.id}
toast={toast}
onDismiss={() => removeToast(toast.id)}
/>
))}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useState, useEffect } from 'react';
interface ClientPlatform {
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
fileManagerName: string;
}
const DEFAULT: ClientPlatform = {
isWindows: false,
isMac: false,
isLinux: false,
fileManagerName: 'File Manager',
};
export function useClientPlatform(): ClientPlatform {
/* eslint-disable react-hooks/set-state-in-effect */
const [platform, setPlatform] = useState<ClientPlatform>(DEFAULT);
useEffect(() => {
// Check electronAPI first (most reliable in Electron)
const electronPlatform = (window as unknown as { electronAPI?: { versions?: { platform?: string } } }).electronAPI?.versions?.platform;
const raw = electronPlatform || navigator.platform || '';
const isWindows = raw === 'win32' || /^Win/i.test(raw);
const isMac = raw === 'darwin' || /^Mac/i.test(raw);
const isLinux = raw === 'linux' || /^Linux/i.test(raw);
setPlatform({
isWindows,
isMac,
isLinux,
fileManagerName: isWindows ? 'Explorer' : isMac ? 'Finder' : 'File Manager',
});
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
return platform;
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface Toast {
id: string;
type: ToastType;
message: string;
action?: ToastAction;
duration?: number;
}
const MAX_TOASTS = 3;
const DEFAULT_DURATION = 5000;
const ERROR_DURATION = 8000;
let globalAddToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
/** Imperatively show a toast from anywhere */
export function showToast(toast: Omit<Toast, 'id'>) {
if (globalAddToast) {
globalAddToast(toast);
}
}
export function useToastState() {
const [toasts, setToasts] = useState<Toast[]>([]);
const counterRef = useRef(0);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
}, []);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = `toast-${++counterRef.current}`;
const duration = toast.duration ?? (toast.type === 'error' ? ERROR_DURATION : DEFAULT_DURATION);
setToasts(prev => {
const next = [...prev, { ...toast, id }];
// FIFO eviction
while (next.length > MAX_TOASTS) {
const evicted = next.shift()!;
const timer = timersRef.current.get(evicted.id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(evicted.id);
}
}
return next;
});
const timer = setTimeout(() => removeToast(id), duration);
timersRef.current.set(id, timer);
}, [removeToast]);
// Register as global handler
useEffect(() => {
globalAddToast = addToast;
return () => {
if (globalAddToast === addToast) globalAddToast = null;
};
}, [addToast]);
// Cleanup on unmount
useEffect(() => {
const timers = timersRef.current;
return () => {
timers.forEach(t => clearTimeout(t));
};
}, []);
return { toasts, addToast, removeToast };
}
+70
View File
@@ -984,6 +984,76 @@ const en = {
'terminal.connecting': 'Connecting...',
'terminal.disconnected': 'Disconnected',
'terminal.exited': 'Process exited',
// ── Setup Center ──────────────────────────────────────────────
'setup.title': 'Welcome to CodePilot',
'setup.subtitle': 'Let\'s get you set up in a few quick steps.',
'setup.skipAndEnter': 'Skip and Enter',
'setup.progress': '{completed}/3 completed',
'setup.openSetupCenter': 'Initial Setup Guide',
'setup.openSetupCenterDesc': 'Check or reconfigure Claude Code connection, API provider, and default project directory',
'setup.open': 'Open',
// Setup: Welcome card
'setup.welcome.title': 'Welcome',
'setup.welcome.description': 'CodePilot is a desktop GUI for Claude Code. It connects to the Claude Code CLI to provide a visual interface for AI-assisted coding.',
// Setup: Claude Code card
'setup.claude.title': 'Claude Code CLI',
'setup.claude.description': 'CodePilot requires the Claude Code CLI to function.',
'setup.claude.detected': 'Claude Code detected',
'setup.claude.notFound': 'Claude Code not found',
'setup.claude.conflict': 'Multiple installations detected — this may cause version conflicts',
'setup.claude.conflictUsing': 'Currently using',
'setup.claude.conflictOther': 'Also found',
'setup.claude.conflictRemoveHint': 'To remove, run:',
'setup.claude.conflictResolved': 'After cleanup, click Re-detect to verify.',
'setup.claude.missingGit': 'Git for Windows required',
'setup.claude.useRecommended': 'Use Recommended',
'setup.claude.install': 'Install',
'setup.claude.redetect': 'Re-detect',
'setup.claude.viewCleanup': 'View Cleanup',
'setup.claude.skip': 'Skip',
// Setup: Provider card
'setup.provider.title': 'API Provider',
'setup.provider.description': 'Configure how CodePilot connects to AI models.',
'setup.provider.envDetected': 'Environment variables detected — use Claude Code env as your provider.',
'setup.provider.useEnv': 'Use Claude Code Env',
'setup.provider.noProvider': 'No provider configured. Select a preset to get started.',
'setup.provider.configured': 'Provider configured',
'setup.provider.skip': 'Skip',
// Setup: Project directory card
'setup.project.title': 'Project Directory',
'setup.project.description': 'Choose a default project directory to start working in.',
'setup.project.selectDirectory': 'Select Directory',
'setup.project.recentProjects': 'Recent Projects',
'setup.project.noRecent': 'No recent projects found.',
'setup.project.selected': 'Project directory set',
'setup.project.skip': 'Skip',
// Chat empty state
'chat.empty.noDirectory': 'Select a project directory to get started',
'chat.empty.noProvider': 'Configure an API provider to start chatting',
'chat.empty.openSetup': 'Open Setup',
'chat.empty.selectFolder': 'Select Folder',
'chat.empty.skipForNow': 'Skip for now',
'chat.empty.ready': 'Ready to chat. Type a message to get started.',
'chat.empty.recentProjects': 'Recent Projects',
// Platform
'platform.openInFileManager': 'Double-click to open in {fileManager}',
// Error messages
'error.sessionCreateFailed': 'Failed to create session',
'error.titleSaveFailed': 'Failed to save title',
'error.pushFailed': 'Push failed',
'error.directoryInvalid': 'Directory no longer exists',
'error.providerUnavailable': 'No API provider available',
'error.retry': 'Retry',
'error.selectDirectory': 'Select Directory',
'error.openSetup': 'Open Setup',
} as const;
export type TranslationKey = keyof typeof en;
+70
View File
@@ -981,6 +981,76 @@ const zh: Record<TranslationKey, string> = {
'terminal.connecting': '连接中...',
'terminal.disconnected': '已断开',
'terminal.exited': '进程已退出',
// ── Setup Center ──────────────────────────────────────────────
'setup.title': '欢迎使用 CodePilot',
'setup.subtitle': '让我们快速完成几个设置步骤。',
'setup.skipAndEnter': '跳过并进入',
'setup.progress': '{completed}/3 已完成',
'setup.openSetupCenter': '首次设置引导',
'setup.openSetupCenterDesc': '检查或重新配置 Claude Code 连接、API 提供商和默认项目目录',
'setup.open': '打开',
// Setup: Welcome card
'setup.welcome.title': '欢迎',
'setup.welcome.description': 'CodePilot 是 Claude Code 的桌面 GUI 客户端。它连接到 Claude Code CLI,提供 AI 辅助编码的可视化界面。',
// Setup: Claude Code card
'setup.claude.title': 'Claude Code CLI',
'setup.claude.description': 'CodePilot 需要 Claude Code CLI 才能正常工作。',
'setup.claude.detected': '已检测到 Claude Code',
'setup.claude.notFound': '未找到 Claude Code',
'setup.claude.conflict': '检测到多个安装版本,可能导致版本冲突',
'setup.claude.conflictUsing': '当前使用',
'setup.claude.conflictOther': '另外发现',
'setup.claude.conflictRemoveHint': '卸载命令:',
'setup.claude.conflictResolved': '清理完成后,点击"重新检测"以确认。',
'setup.claude.missingGit': '需要安装 Git for Windows',
'setup.claude.useRecommended': '使用推荐版本',
'setup.claude.install': '安装',
'setup.claude.redetect': '重新检测',
'setup.claude.viewCleanup': '查看清理方式',
'setup.claude.skip': '跳过',
// Setup: Provider card
'setup.provider.title': 'API 服务商',
'setup.provider.description': '配置 CodePilot 连接 AI 模型的方式。',
'setup.provider.envDetected': '检测到环境变量 — 使用 Claude Code 环境变量作为服务商。',
'setup.provider.useEnv': '使用 Claude Code 环境',
'setup.provider.noProvider': '未配置服务商。选择一个预设开始。',
'setup.provider.configured': '服务商已配置',
'setup.provider.skip': '跳过',
// Setup: Project directory card
'setup.project.title': '项目目录',
'setup.project.description': '选择一个默认项目目录以开始工作。',
'setup.project.selectDirectory': '选择目录',
'setup.project.recentProjects': '最近的项目',
'setup.project.noRecent': '暂无最近的项目。',
'setup.project.selected': '已设置项目目录',
'setup.project.skip': '跳过',
// Chat empty state
'chat.empty.noDirectory': '选择一个项目目录以开始',
'chat.empty.noProvider': '配置 API 服务商以开始对话',
'chat.empty.openSetup': '打开设置',
'chat.empty.selectFolder': '选择文件夹',
'chat.empty.skipForNow': '暂时跳过',
'chat.empty.ready': '准备就绪。输入消息开始对话。',
'chat.empty.recentProjects': '最近的项目',
// Platform
'platform.openInFileManager': '双击在{fileManager}中打开',
// Error messages
'error.sessionCreateFailed': '创建会话失败',
'error.titleSaveFailed': '保存标题失败',
'error.pushFailed': '推送失败',
'error.directoryInvalid': '目录已不存在',
'error.providerUnavailable': '无可用的 API 服务商',
'error.retry': '重试',
'error.selectDirectory': '选择目录',
'error.openSetup': '打开设置',
};
export default zh;
+1
View File
@@ -64,6 +64,7 @@ interface ElectronAPI {
electron: string;
node: string;
chrome: string;
platform: string;
};
shell: {
openPath: (path: string) => Promise<string>;
+13
View File
@@ -563,6 +563,19 @@ export const SETTING_KEYS = {
ASSISTANT_WORKSPACE_PATH: 'assistant_workspace_path',
} as const;
// ==========================================
// Setup Center Types
// ==========================================
export type SetupCardStatus = 'not-configured' | 'completed' | 'skipped' | 'needs-fix';
export interface SetupState {
completed: boolean;
claude: SetupCardStatus;
provider: SetupCardStatus;
project: SetupCardStatus;
defaultProject?: string;
}
// ==========================================
// Assistant Workspace Types
// ==========================================