From 8154130035f582809a401a661caafd46f38b6dc8 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 13 Mar 2026 15:19:52 +0800 Subject: [PATCH] feat: add Setup Center onboarding and move commit/push to Git panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/handover/README.md | 1 + docs/handover/onboarding-setup-center.md | 221 +++++++++++++++++++ electron/preload.ts | 1 + src/app/api/setup/recent-projects/route.ts | 31 +++ src/app/api/setup/route.ts | 109 +++++++++ src/app/chat/page.tsx | 193 +++++++++++++--- src/components/chat/ChatEmptyState.tsx | 84 +++++++ src/components/git/GitStatusSection.tsx | 68 +++++- src/components/layout/AppShell.tsx | 34 +++ src/components/layout/ChatListPanel.tsx | 47 +++- src/components/layout/ConnectionStatus.tsx | 11 +- src/components/layout/ProjectGroupHeader.tsx | 13 +- src/components/layout/UnifiedTopBar.tsx | 109 +-------- src/components/settings/GeneralSection.tsx | 16 ++ src/components/setup/ClaudeCodeCard.tsx | 193 ++++++++++++++++ src/components/setup/ProjectDirCard.tsx | 128 +++++++++++ src/components/setup/ProviderCard.tsx | 118 ++++++++++ src/components/setup/SetupCard.tsx | 69 ++++++ src/components/setup/SetupCenter.tsx | 136 ++++++++++++ src/components/setup/WelcomeCard.tsx | 16 ++ src/components/ui/error-banner.tsx | 54 +++++ src/components/ui/toast.tsx | 66 ++++++ src/hooks/useClientPlatform.ts | 42 ++++ src/hooks/useToast.ts | 86 ++++++++ src/i18n/en.ts | 70 ++++++ src/i18n/zh.ts | 70 ++++++ src/types/electron.d.ts | 1 + src/types/index.ts | 13 ++ 28 files changed, 1852 insertions(+), 148 deletions(-) create mode 100644 docs/handover/onboarding-setup-center.md create mode 100644 src/app/api/setup/recent-projects/route.ts create mode 100644 src/app/api/setup/route.ts create mode 100644 src/components/chat/ChatEmptyState.tsx create mode 100644 src/components/setup/ClaudeCodeCard.tsx create mode 100644 src/components/setup/ProjectDirCard.tsx create mode 100644 src/components/setup/ProviderCard.tsx create mode 100644 src/components/setup/SetupCard.tsx create mode 100644 src/components/setup/SetupCenter.tsx create mode 100644 src/components/setup/WelcomeCard.tsx create mode 100644 src/components/ui/error-banner.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/hooks/useClientPlatform.ts create mode 100644 src/hooks/useToast.ts diff --git a/docs/handover/README.md b/docs/handover/README.md index 328d7ee..aadfd06 100644 --- a/docs/handover/README.md +++ b/docs/handover/README.md @@ -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 适配 | diff --git a/docs/handover/onboarding-setup-center.md b/docs/handover/onboarding-setup-center.md new file mode 100644 index 0000000..731ef04 --- /dev/null +++ b/docs/handover/onboarding-setup-center.md @@ -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`:`` 组件,渲染在 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` 接口,额外产生一次文件系统访问 diff --git a/electron/preload.ts b/electron/preload.ts index 32b4076..c971d37 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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), diff --git a/src/app/api/setup/recent-projects/route.ts b/src/app/api/setup/recent-projects/route.ts new file mode 100644 index 0000000..bca1d0c --- /dev/null +++ b/src/app/api/setup/recent-projects/route.ts @@ -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: [] }); + } +} diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts new file mode 100644 index 0000000..ef9a50a --- /dev/null +++ b/src/app/api/setup/route.ts @@ -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 }); + } +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index a6d66ae..f0ce670 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -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([]); const [streamingContent, setStreamingContent] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [toolUses, setToolUses] = useState([]); const [toolResults, setToolResults] = useState([]); const [statusText, setStatusText] = useState(); - 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([]); + 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 => { + 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 (
- + {messages.length === 0 && !isStreaming && (!workingDir.trim() || !hasProvider) ? ( + + ) : ( + + )} + {errorBanner && ( + setErrorBanner(null)} + actions={[ + { label: t('error.retry'), onClick: () => setErrorBanner(null) }, + ]} + /> + )} } /> +
); } diff --git a/src/components/chat/ChatEmptyState.tsx b/src/components/chat/ChatEmptyState.tsx new file mode 100644 index 0000000..0c45366 --- /dev/null +++ b/src/components/chat/ChatEmptyState.tsx @@ -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 ( +
+

{t('chat.empty.ready')}

+
+ ); + } + + return ( +
+
+ {!hasDirectory && ( +
+

{t('chat.empty.noDirectory')}

+
+ + {recentProjects && recentProjects.length > 0 && onSelectProject && ( +
+

{t('chat.empty.recentProjects')}

+
+ {recentProjects.slice(0, 5).map(p => { + const name = p.split(/[\\/]/).filter(Boolean).pop() || p; + return ( + + ); + })} +
+
+ )} +
+
+ )} + + {!hasProvider && ( +
+

{t('chat.empty.noProvider')}

+ +
+ )} +
+
+ ); +} diff --git a/src/components/git/GitStatusSection.tsx b/src/components/git/GitStatusSection.tsx index 5c0a907..4f8be4d 100644 --- a/src/components/git/GitStatusSection.tsx +++ b/src/components/git/GitStatusSection.tsx @@ -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) { ); })()} + + {/* Commit & Push actions */} +
+ + +
+ + setCommitDialogOpen(false)} + onSuccess={handleCommitSuccess} + /> ); } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 2d68717..3444e37 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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 }) { + + {setupOpen && ( + setSetupOpen(false)} + initialCard={setupInitialCard} + /> + )} diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 62a8c44..bb69314 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -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) => { diff --git a/src/components/layout/ConnectionStatus.tsx b/src/components/layout/ConnectionStatus.tsx index 42baef2..b4929f5 100644 --- a/src/components/layout/ConnectionStatus.tsx +++ b/src/components/layout/ConnectionStatus.tsx @@ -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); diff --git a/src/components/layout/ProjectGroupHeader.tsx b/src/components/layout/ProjectGroupHeader.tsx index 60782c9..baedd09 100644 --- a/src/components/layout/ProjectGroupHeader.tsx +++ b/src/components/layout/ProjectGroupHeader.tsx @@ -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 ( @@ -97,20 +102,20 @@ export function ProjectGroupHeader({ > - New chat in {displayName} + {t('chatList.newConversation')} - {displayName} - New chat in {displayName} + {t('chatList.newConversation')} - {displayName} )} -

{workingDirectory || 'No Project'}

- {workingDirectory &&

Double-click to open in Finder

} +

{workingDirectory || t('chatList.noSessions')}

+ {workingDirectory &&

{t('platform.openInFileManager', { fileManager: fileManagerName })}

}
); diff --git a/src/components/layout/UnifiedTopBar.tsx b/src/components/layout/UnifiedTopBar.tsx index a3e6f85..d574954 100644 --- a/src/components/layout/UnifiedTopBar.tsx +++ b/src/components/layout/UnifiedTopBar.tsx @@ -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 */} -
- - - - - - - setCommitDialogOpen(true)}> - - {t('topBar.commit')} - - - - {pushing ? t('git.loading') : t('topBar.push')} - - - -
- {pushMessage && ( - - {pushMessage.text} - - )} - + + {/* Appearance */} diff --git a/src/components/setup/ClaudeCodeCard.tsx b/src/components/setup/ClaudeCodeCard.tsx new file mode 100644 index 0000000..b08bdf4 --- /dev/null +++ b/src/components/setup/ClaudeCodeCard.tsx @@ -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 = { + 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 ( +
+ {command} + +
+ ); +} + +export function ClaudeCodeCard({ status, onStatusChange }: ClaudeCodeCardProps) { + const { t } = useTranslation(); + const [claudeStatus, setClaudeStatus] = useState(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 ( + + {checking ? ( +

Checking...

+ ) : claudeStatus?.connected ? ( +
+

v{claudeStatus.version} ({claudeStatus.installType})

+ {claudeStatus.binaryPath && ( +

{claudeStatus.binaryPath}

+ )} + {(claudeStatus.otherInstalls?.length ?? 0) > 0 && ( +
+

{t('setup.claude.conflict')}

+
+

{t('setup.claude.conflictUsing')}:

+

+ {claudeStatus.binaryPath} + {' '}({INSTALL_TYPE_LABELS[claudeStatus.installType || 'unknown']} v{claudeStatus.version}) +

+
+ {!showCleanup ? ( + + ) : ( +
+ {claudeStatus.otherInstalls?.map((inst, i) => { + const cmd = getUninstallCommand(inst.type); + return ( +
+

+ {t('setup.claude.conflictOther')}:{' '} + {inst.path} + {' '}({INSTALL_TYPE_LABELS[inst.type]} {inst.version && `v${inst.version}`}) +

+ {cmd && ( +
+

{t('setup.claude.conflictRemoveHint')}

+ +
+ )} +
+ ); + })} +

{t('setup.claude.conflictResolved')}

+ +
+ )} +
+ )} +
+ ) : ( +
+ {claudeStatus?.missingGit ? ( +
+

{t('install.gitDescription')}

+
    +
  1. {t('install.gitStep1')}
  2. +
  3. {t('install.gitStep2')}
  4. +
  5. {t('install.gitStep3')}
  6. +
+
+ ) : ( +
+

{t('setup.claude.notFound')}

+ + {typeof navigator !== 'undefined' && navigator.platform?.startsWith('Win') + ? 'irm https://claude.ai/install.ps1 | iex' + : 'curl -fsSL https://claude.ai/install.sh | bash'} + +
+ )} + +
+ )} +
+ ); +} diff --git a/src/components/setup/ProjectDirCard.tsx b/src/components/setup/ProjectDirCard.tsx new file mode 100644 index 0000000..5c218c3 --- /dev/null +++ b/src/components/setup/ProjectDirCard.tsx @@ -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([]); + const [folderPickerOpen, setFolderPickerOpen] = useState(false); + const [selectedPath, setSelectedPath] = useState(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 ( + <> + + {status === 'completed' ? ( +

{selectedPath}

+ ) : ( +
+ {recentProjects.length > 0 && ( +
+

{t('setup.project.recentProjects')}

+
+ {recentProjects.map(p => { + const name = p.split(/[\\/]/).filter(Boolean).pop() || p; + return ( + + ); + })} +
+
+ )} + +
+ )} +
+ + + + ); +} diff --git a/src/components/setup/ProviderCard.tsx b/src/components/setup/ProviderCard.tsx new file mode 100644 index 0000000..9f6a553 --- /dev/null +++ b/src/components/setup/ProviderCard.tsx @@ -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([]); + 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 ( + + {loading ? ( +

Loading...

+ ) : status === 'completed' ? ( +

+ {providers.length > 0 + ? `${providers.length} provider(s) configured` + : envDetected + ? 'Using Claude Code environment' + : ''} +

+ ) : ( +
+ {envDetected ? ( +
+

{t('setup.provider.envDetected')}

+ +
+ ) : ( +
+

{t('setup.provider.noProvider')}

+ +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/setup/SetupCard.tsx b/src/components/setup/SetupCard.tsx new file mode 100644 index 0000000..3ebfc46 --- /dev/null +++ b/src/components/setup/SetupCard.tsx @@ -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 ( +
+
+
+ {icon &&
{icon}
} +
+

{title}

+

{description}

+
+
+
+ + {statusCfg.label} +
+
+ + {status !== 'completed' && status !== 'skipped' && ( +
+ {children} + {onSkip && ( + + )} +
+ )} + + {(status === 'completed' || status === 'skipped') && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/components/setup/SetupCenter.tsx b/src/components/setup/SetupCenter.tsx new file mode 100644 index 0000000..5c1e313 --- /dev/null +++ b/src/components/setup/SetupCenter.tsx @@ -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('not-configured'); + const [providerStatus, setProviderStatus] = useState('not-configured'); + const [projectStatus, setProjectStatus] = useState('not-configured'); + const [defaultProject, setDefaultProject] = useState(); + // 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(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 ( +
+
+ {/* Header */} +
+
+

{t('setup.title')}

+

{t('setup.subtitle')}

+
+
+ + {t('setup.progress', { completed: String(completedCount) })} + + +
+
+ + {/* Cards */} +
+ + +
+ +
+ +
+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/setup/WelcomeCard.tsx b/src/components/setup/WelcomeCard.tsx new file mode 100644 index 0000000..9fd9aa5 --- /dev/null +++ b/src/components/setup/WelcomeCard.tsx @@ -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 ( +
+

{t('setup.welcome.title')}

+

{t('setup.welcome.description')}

+

v{version}

+
+ ); +} diff --git a/src/components/ui/error-banner.tsx b/src/components/ui/error-banner.tsx new file mode 100644 index 0000000..0f9f8d3 --- /dev/null +++ b/src/components/ui/error-banner.tsx @@ -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 ( +
+ +
+

{message}

+ {description &&

{description}

} + {actions && actions.length > 0 && ( +
+ {actions.map((action, i) => ( + + ))} +
+ )} +
+ {onDismiss && ( + + )} +
+ ); +} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..974bf66 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -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 ( +
+ + {toast.message} + {toast.action && ( + + )} + +
+ ); +} + +export function Toaster() { + const { toasts, removeToast } = useToastState(); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map(toast => ( + removeToast(toast.id)} + /> + ))} +
+ ); +} diff --git a/src/hooks/useClientPlatform.ts b/src/hooks/useClientPlatform.ts new file mode 100644 index 0000000..c886065 --- /dev/null +++ b/src/hooks/useClientPlatform.ts @@ -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(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; +} diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..42e2c4e --- /dev/null +++ b/src/hooks/useToast.ts @@ -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) => void) | null = null; + +/** Imperatively show a toast from anywhere */ +export function showToast(toast: Omit) { + if (globalAddToast) { + globalAddToast(toast); + } +} + +export function useToastState() { + const [toasts, setToasts] = useState([]); + const counterRef = useRef(0); + const timersRef = useRef>>(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) => { + 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 }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d81877d..60c58a5 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index abb540f..0fa7b60 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -981,6 +981,76 @@ const zh: Record = { '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; diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 793fef2..0642eec 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -64,6 +64,7 @@ interface ElectronAPI { electron: string; node: string; chrome: string; + platform: string; }; shell: { openPath: (path: string) => Promise; diff --git a/src/types/index.ts b/src/types/index.ts index 1f68c95..e40328f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 // ==========================================