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:
1 parent
e6d707fe21
commit
8154130035
28 files changed
+1852
-148
No files matched your search
@@ -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 适配 |
|
||||
@@ -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` 接口,额外产生一次文件系统访问
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Vendored
+1
@@ -64,6 +64,7 @@ interface ElectronAPI {
|
||||
electron: string;
|
||||
node: string;
|
||||
chrome: string;
|
||||
platform: string;
|
||||
};
|
||||
shell: {
|
||||
openPath: (path: string) => Promise<string>;
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
Reference in new issue
Block a user