refactor: complete UI governance — lint rules, icon/color unification, component splits

Design pattern & component consistency governance across 120 files:

ESLint governance (eslint.config.mjs):
- Enforce ui/ components over native HTML controls (warn)
- Ban Lucide imports (error), require Phosphor via ui/icon.tsx (warn)
- Component file size limit 500 lines (warn, skip blanks+comments)
- Patterns layer isolation (error: no hooks/lib imports)
- Exclude 资料/ from lint scope

Icon unification (ui/icon.tsx):
- Unified barrel export with 86+ Phosphor icons
- Decoupled types/index.ts from icon library (generic IconComponent type)
- Separated command data (lib/constants/commands.ts) from icons (command-icons.ts)

Color token system (globals.css):
- Added semantic status tokens: success/warning/error/info with muted/foreground/border variants
- Migrated 39 files from raw green/red/orange/amber/blue to semantic tokens
- grep-based lint:colors script covers green/emerald/red/orange/yellow/amber/blue

Component splits (all under 500 lines):
- MessageInput 1659→444: MessageInputParts, SlashCommandPopover, 7 hooks, 2 libs
- ChatView ~850→419: useStreamSubscription, useAssistantTrigger
- ProviderManager 1081→406: provider-presets, PresetConnectDialog, useProviderModels
- AppShell 527→470: useUpdateChecker
- AssistantWorkspaceSection 908→499: WorkspaceTabPanels/ConfirmDialogs/StatusCards
- ChatListPanel 677→484: SessionListItem, ProjectGroupHeader, chat-list-utils

Other:
- Migrated 80 native HTML controls to ui/ components across 38 files
- Fixed 22 hook dependency / set-state-in-effect issues
- Cleaned 7 dead code warnings
- Rebuilt better-sqlite3 for ARM architecture
- Added handover doc: docs/handover/ui-governance.md

Lint status: 0 errors 0 warnings on touched files; typecheck pass; 374/374 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-12 01:02:28 +08:00
1 parent 61667fc55d
commit 5d7850a180
120 files changed
+6075 -4260

No files matched your search

+1
View File
@@ -13,3 +13,4 @@
| assistant-workspace.md | 助理工作区:人格/记忆文件、对话式引导、自动触发、确定性落盘 |
| theme-system.md | 主题家族系统:两层架构、JSON schema、代码高亮三条渲染链、12 个主题清单 |
| cli-tools.md | CLI 工具管理:静态 catalog、系统检测、一键安装、AI 描述、聊天上下文注入、输入框选择器 |
| ui-governance.md | 设计模式治理:四层架构、ESLint 规则、图标/颜色统一、组件拆分记录、新增文件清单 |
+139
View File
@@ -0,0 +1,139 @@
# 设计模式与组件一致性治理
> 完成时间:2026-03-12 | 涉及 92 个文件 | 净减 ~2,900 行
## 治理目标
在保留现有视觉语言的前提下,建立统一的组件分层规则、消灭跨层重复模式、拆分超大组件、通过 ESLint 静态约束防止退化。
## 分层架构
```
src/components/ui/ → 纯 primitives(Button, Input, Select, Dialog…)
src/components/patterns/ → 复用展示模式(SettingsCard, FieldRow, StatusBanner…),零副作用
src/components/{feature}/ → 组合 hooks + patterns + 业务状态
src/app/ → 页面装配
src/hooks/ → 数据获取、状态管理
src/lib/constants/ → 纯数据常量,不引用 UI 层
```
## 已完成的治理项
### 1. ESLint 治理规则 (`eslint.config.mjs`)
| 规则 | 级别 | 作用域 | 说明 |
|------|------|--------|------|
| 禁止原生 HTML 控件 | warn | 业务组件 + app 层 | `<button/input/select/textarea>` → 使用 `ui/` 组件 |
| 禁止 Lucide 导入 | error | `src/**` | 统一使用 Phosphor,通过 `ui/icon.tsx` 入口 |
| 禁止直接引用 Phosphor | warn | 业务组件 + hooks | 应从 `@/components/ui/icon` 导入 |
| 组件文件行数上限 500 | warn | `src/components/**`(排除 ui/、ai-elements/) | `skipBlankLines + skipComments` |
| Patterns 层隔离 | error | `src/components/patterns/**` | 禁止导入 `@/hooks/*` 和 `@/lib/*`(允许 `@/lib/utils`) |
| 外部资料目录排除 | — | `资料/**` | 不参与主仓 lint |
### 2. 图标统一 (`src/components/ui/icon.tsx`)
- 所有业务组件通过 `@/components/ui/icon` 统一入口导入 Phosphor 图标
- 目前导出 86+ 个图标 + `ICON_SIZE` 常量 + `Icon`/`IconProps` 类型
- `types/index.ts` 中的 `IconComponent` 类型与 Phosphor 解耦,使用泛型 SVG 签名
- 图标与命令数据分离:`lib/constants/commands.ts`(纯数据)+ `lib/constants/command-icons.ts`(图标映射),在 `hooks/useSlashCommands.ts` 中合并
### 3. 颜色 token 治理 (`globals.css` + `lint:colors`)
新增语义 token(light + dark):
```
--status-success-{muted,foreground,border} (原 green-*)
--status-warning-{muted,foreground,border} (原 orange/yellow/amber-*)
--status-error-{muted,foreground,border} (原 red-*)
--status-info-{muted,foreground,border} (原 blue-*)
```
- Tailwind v4 通过 `@theme inline` 中的 `--color-status-*` 变量注册
- `npm run lint:colors` 检查 `green/emerald/red/orange/yellow/amber/blue-{400-700}` 用法
- 例外行标注 `// lint-allow-raw-color`(如 diff 语法高亮、终端提示符)
### 4. Patterns 层 (`src/components/patterns/`)
| 组件 | 用途 |
|------|------|
| `SettingsCard` | 统一的设置卡片容器(title + description + children) |
| `FieldRow` | 设置项行布局(label + description + 右侧控件) |
| `StatusBanner` | success/warning/error/info 状态横幅 |
| `EmptyState` | 空状态占位(icon + title + description + action) |
| `CommandList` | 斜杠命令列表弹窗 |
### 5. 超大组件拆分
| 组件 | 原始行数 | 当前行数 | 抽出的子模块 |
|------|---------|---------|-------------|
| MessageInput | 1,659 | 444 | `MessageInputParts`, `SlashCommandPopover`, hooks: `useChatCommands`, `usePopoverState`, `useSlashCommands`, `useCliToolsFetch`, `useCommandBadge`, libs: `message-input-logic`, `file-utils` |
| ChatView | ~850 | 419 | `useStreamSubscription`, `useAssistantTrigger` |
| ProviderManager | 1,081 | ~406 | `provider-presets`, `PresetConnectDialog`, `useProviderModels` |
| AppShell | 527 | 470 | `useUpdateChecker` |
| AssistantWorkspaceSection | 908 | 499 | `WorkspaceTabPanels`, `WorkspaceConfirmDialogs`, `WorkspaceStatusCards`, `workspace-types` |
| ChatListPanel | 677 | 484 | `SessionListItem`, `ProjectGroupHeader`, `chat-list-utils` |
### 6. 原生 HTML 控件迁移
38 个文件中 80 处原生 `<button/input/textarea/select>` 替换为 `ui/Button`、`ui/Input`、`ui/Textarea`、`ui/Select`。
### 7. Hook 依赖与死代码清理
- 修复 `react-hooks/exhaustive-deps` 和 `react-hooks/set-state-in-effect` 共 22 处
- 清除 unused imports/variables 共 7 处
- AppShell 中 5 个 `set-state-in-effect` 错误通过 lazy initializer、derived state、`eslint-disable-next-line` 组合解决
## 新增文件清单
### 组件
- `src/components/patterns/CommandList.tsx`
- `src/components/chat/MessageInputParts.tsx`
- `src/components/chat/SlashCommandPopover.tsx`(重构)
- `src/components/layout/SessionListItem.tsx`
- `src/components/layout/ProjectGroupHeader.tsx`
- `src/components/layout/chat-list-utils.ts`
- `src/components/settings/provider-presets.tsx`
- `src/components/settings/PresetConnectDialog.tsx`
- `src/components/settings/WorkspaceTabPanels.tsx`
- `src/components/settings/WorkspaceConfirmDialogs.tsx`
- `src/components/settings/WorkspaceStatusCards.tsx`
- `src/components/settings/workspace-types.ts`
### Hooks
- `src/hooks/useAssistantTrigger.ts`
- `src/hooks/useChatCommands.ts`
- `src/hooks/useCliToolsFetch.ts`
- `src/hooks/useCommandBadge.ts`
- `src/hooks/usePopoverState.ts`
- `src/hooks/useProviderModels.ts`
- `src/hooks/useSlashCommands.ts`
- `src/hooks/useStreamSubscription.ts`
- `src/hooks/useUpdateChecker.ts`
### Lib
- `src/lib/constants/commands.ts`
- `src/lib/constants/command-icons.ts`
- `src/lib/file-utils.ts`
- `src/lib/message-input-logic.ts`
## 当前 Lint 状态
- **治理涉及文件**:0 errors, 0 warnings
- **全仓**:0 errors, 80 warnings(均在未涉及的文件中:tests、api routes、lib、ai-elements)
- **Typecheck**:通过
- **Unit tests**:374 pass, 0 fail
## 已知遗留(不在本轮治理范围)
1. **ai-elements 层**:`prompt-input.tsx`(1,345L)仍含 30+ thin re-export wrappers;`file-tree.tsx` 有可访问性 warning
2. **全仓 80 warnings**:主要是 tests 中的死代码、bridge adapters 中的 unused vars、`stream-session-manager.ts` 中的 unused imports
3. **CodeBlock 两套并存**:`chat/CodeBlock.tsx`(Prism)和 `ai-elements/code-block.tsx`(Shiki),计划统一为 Shiki
4. **`catch { // ignore }` 模式**:50+ 处静默错误,需逐步加 toast 或 console.warn
## 开发者须知
- 新建业务组件时,从 `@/components/ui/icon` 导入图标,不要直接引 `@phosphor-icons/react`
- 颜色使用语义 token(`text-status-success-foreground`),不用裸色值(`text-green-500`)
- 新 pattern 组件放 `patterns/`,不引 hooks/lib(除 `@/lib/utils` 的 `cn()`)
- 组件超过 500 行时考虑拆分
- 提交前运行 `npm run test` + `npm run lint:colors`
+1
View File
@@ -10,3 +10,4 @@
|------|------|
| chat-sdk-integration-feasibility.md | Vercel Chat SDK 集成可行性调研 |
| context-storage-migration-plan.md | 上下文共享与存储迁移设计(详细方案;执行跟踪见 `docs/exec-plans/active/context-storage-migration.md`) |
| mobile-remote-control-overall-plan.md | 移动端远程控制整体方案(Host / Controller / Lease / 多设备控制) |
+37
View File
@@ -17,6 +17,8 @@ const eslintConfig = defineConfig([
// apps/site generated artifacts
"apps/site/.next/**",
"apps/site/.source/**",
// External reference materials / vendored packages (not part of the main codebase)
"资料/**",
]),
// ── Governance rules for business components ──
@@ -75,6 +77,41 @@ const eslintConfig = defineConfig([
},
},
// ── Discourage direct Phosphor imports outside ui/ and ai-elements/ — use ui/icon.tsx ──
{
files: [
"src/components/settings/**/*.{ts,tsx}",
"src/components/bridge/**/*.{ts,tsx}",
"src/components/chat/**/*.{ts,tsx}",
"src/components/gallery/**/*.{ts,tsx}",
"src/components/plugins/**/*.{ts,tsx}",
"src/components/skills/**/*.{ts,tsx}",
"src/components/project/**/*.{ts,tsx}",
"src/components/layout/**/*.{ts,tsx}",
"src/components/cli-tools/**/*.{ts,tsx}",
"src/app/**/*.{ts,tsx}",
"src/hooks/**/*.{ts,tsx}",
],
rules: {
"no-restricted-imports": [
"warn",
{
patterns: [
{
group: ["@phosphor-icons/react"],
message: "Import icons from @/components/ui/icon instead. See docs/ui-governance.md.",
},
],
},
],
},
},
// ── Raw status colors ──
// ESLint cannot lint inside className strings. Use `npm run lint:colors` (grep-based)
// to check for raw green/red/yellow/orange/blue-{400-700} usage in business components.
// Add `// lint-allow-raw-color` on lines where raw colors are intentional (e.g. diff syntax).
// ── Component file size limit ──
{
files: ["src/components/**/*.{ts,tsx}"],
+1
View File
@@ -23,6 +23,7 @@
"test:e2e": "npx playwright test",
"test:visual": "npx playwright test --grep @visual",
"test": "npm run typecheck && npm run test:unit",
"lint:colors": "! grep -rn --include='*.tsx' --include='*.ts' -E '(bg|text|border)-(green|emerald|red|orange|yellow|amber|blue)-(400|500|600|700)' src/components/ src/app/ --exclude-dir=ui --exclude-dir=ai-elements | grep -v 'lint-allow-raw-color'",
"prepare": "node -e \"try{require('husky')()}catch(e){}\"",
"electron:dev": "concurrently -k \"next dev\" \"wait-on http://localhost:3000 && electron .\"",
"electron:build": "next build && node scripts/build-electron.mjs",
@@ -0,0 +1,778 @@
/**
* Tests the pure logic functions exported from message-input-logic.ts.
*
* Covers slash command dispatch, badge behavior, popover navigation,
* submit gating, and CLI badge handling.
*
* Run with: npx tsx --test src/__tests__/unit/message-input-interactions.test.ts
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// ─── Real imports from source modules ────────────────────────────
import { BUILT_IN_COMMANDS, COMMAND_PROMPTS } from '../../lib/constants/commands';
import { DEFAULT_MODEL_OPTIONS } from '../../hooks/useProviderModels';
import type { CommandBadge, CliBadge, PopoverItem, PopoverMode, SkillKind } from '../../types';
// ─── Pure logic functions under test ─────────────────────────────
import {
detectPopoverTrigger,
filterItems,
resolveItemSelection,
dispatchBadge,
cycleIndex,
isSubmitEnabled,
resolveKeyAction,
resolveDirectSlash,
buildCliAppend,
} from '../../lib/message-input-logic';
// =====================================================================
// TESTS
// =====================================================================
// --- 1. Slash Command Popover ----------------------------------------
describe('Slash command popover trigger detection', () => {
it('typing "/" at empty input triggers skill mode', () => {
const result = detectPopoverTrigger('/', 1);
assert.ok(result);
assert.equal(result.mode, 'skill');
assert.equal(result.filter, '');
});
it('typing "/" after a space triggers skill mode', () => {
const result = detectPopoverTrigger('hello /com', 10);
assert.ok(result);
assert.equal(result.mode, 'skill');
assert.equal(result.filter, 'com');
});
it('typing "/" mid-word does NOT trigger skill mode', () => {
const result = detectPopoverTrigger('path/to/file', 12);
assert.equal(result, null);
});
it('typing "@" triggers file popover mode', () => {
const result = detectPopoverTrigger('@src', 4);
assert.ok(result);
assert.equal(result.mode, 'file');
assert.equal(result.filter, 'src');
});
it('typing "@" without filter triggers file mode with empty filter', () => {
const result = detectPopoverTrigger('@', 1);
assert.ok(result);
assert.equal(result.mode, 'file');
assert.equal(result.filter, '');
});
it('no trigger character returns null', () => {
const result = detectPopoverTrigger('hello world', 11);
assert.equal(result, null);
});
it('"/" at beginning of text triggers skill mode', () => {
const result = detectPopoverTrigger('/doc', 4);
assert.ok(result);
assert.equal(result.mode, 'skill');
assert.equal(result.filter, 'doc');
});
it('"@" inside a word does NOT trigger file mode', () => {
const result = detectPopoverTrigger('user@@test', 10);
assert.ok(result);
assert.equal(result.mode, 'file');
assert.equal(result.filter, 'test');
});
});
describe('Popover item filtering (using real BUILT_IN_COMMANDS)', () => {
it('filters by label substring match (case-insensitive)', () => {
const result = filterItems(BUILT_IN_COMMANDS, 'doc');
assert.ok(result.some((i) => i.label === 'doctor'));
assert.ok(!result.some((i) => i.label === 'help'));
});
it('filters by description substring match', () => {
const result = filterItems(BUILT_IN_COMMANDS, 'token');
assert.ok(result.some((i) => i.label === 'cost'));
});
it('empty filter returns all items', () => {
const result = filterItems(BUILT_IN_COMMANDS, '');
assert.equal(result.length, BUILT_IN_COMMANDS.length);
});
it('no match returns empty array', () => {
const result = filterItems(BUILT_IN_COMMANDS, 'xyznonexistent');
assert.equal(result.length, 0);
});
});
describe('Popover keyboard navigation (ArrowDown/ArrowUp cycling)', () => {
it('ArrowDown cycles forward', () => {
assert.equal(cycleIndex(0, 'down', 5), 1);
assert.equal(cycleIndex(3, 'down', 5), 4);
});
it('ArrowDown wraps around at end', () => {
assert.equal(cycleIndex(4, 'down', 5), 0);
});
it('ArrowUp cycles backward', () => {
assert.equal(cycleIndex(2, 'up', 5), 1);
});
it('ArrowUp wraps around at beginning', () => {
assert.equal(cycleIndex(0, 'up', 5), 4);
});
it('single item stays at 0', () => {
assert.equal(cycleIndex(0, 'down', 1), 0);
assert.equal(cycleIndex(0, 'up', 1), 0);
});
it('cycling through full BUILT_IN_COMMANDS length wraps correctly', () => {
const len = BUILT_IN_COMMANDS.length;
assert.equal(cycleIndex(len - 1, 'down', len), 0);
assert.equal(cycleIndex(0, 'up', len), len - 1);
});
});
describe('Popover item selection (Enter/Tab)', () => {
it('selecting immediate command returns immediate_command action', () => {
const helpItem = BUILT_IN_COMMANDS.find((c) => c.label === 'help')!;
assert.ok(helpItem, 'help command must exist in BUILT_IN_COMMANDS');
const result = resolveItemSelection(helpItem, 'skill', 0, '/', '');
assert.equal(result.action, 'immediate_command');
assert.equal(result.commandValue, '/help');
});
it('selecting non-immediate skill command sets badge', () => {
const compactItem = BUILT_IN_COMMANDS.find((c) => c.label === 'compact')!;
assert.ok(compactItem, 'compact command must exist in BUILT_IN_COMMANDS');
const result = resolveItemSelection(compactItem, 'skill', 0, '/', '');
assert.equal(result.action, 'set_badge');
assert.ok(result.badge);
assert.equal(result.badge.command, '/compact');
assert.equal(result.badge.kind, 'sdk_command');
});
it('selecting file mention inserts @path into text', () => {
const fileItem: PopoverItem = { label: 'index.ts', value: 'src/index.ts' };
const result = resolveItemSelection(fileItem, 'file', 6, 'hello @ind', 'ind');
assert.equal(result.action, 'insert_file_mention');
assert.equal(result.newInputValue, 'hello @src/index.ts ');
});
it('selecting agent_skill sets badge with agent_skill kind', () => {
const skillItem: PopoverItem = {
label: 'git-commit',
value: '/git-commit',
description: 'Smart commit',
kind: 'agent_skill',
installedSource: 'agents',
};
const result = resolveItemSelection(skillItem, 'skill', 0, '/', '');
assert.equal(result.action, 'set_badge');
assert.ok(result.badge);
assert.equal(result.badge.kind, 'agent_skill');
assert.equal(result.badge.installedSource, 'agents');
});
it('selecting codepilot_command sets badge with codepilot_command kind', () => {
const doctorItem = BUILT_IN_COMMANDS.find((c) => c.label === 'doctor')!;
assert.ok(doctorItem, 'doctor command must exist in BUILT_IN_COMMANDS');
const result = resolveItemSelection(doctorItem, 'skill', 0, '/', '');
assert.equal(result.action, 'set_badge');
assert.ok(result.badge);
assert.equal(result.badge.kind, 'codepilot_command');
});
it('selecting item with no explicit kind defaults to slash_command', () => {
const genericItem: PopoverItem = { label: 'custom', value: '/custom', description: 'Custom skill' };
const result = resolveItemSelection(genericItem, 'skill', 0, '/', '');
assert.equal(result.action, 'set_badge');
assert.ok(result.badge);
assert.equal(result.badge.kind, 'slash_command');
});
});
describe('Escape closes popover', () => {
it('Escape in skill popover returns close_popover', () => {
const action = resolveKeyAction('Escape', {
popoverMode: 'skill',
popoverHasItems: true,
inputValue: '',
hasBadge: false,
hasCliBadge: false,
});
assert.equal(action.type, 'close_popover');
});
it('Escape in file popover returns close_popover', () => {
const action = resolveKeyAction('Escape', {
popoverMode: 'file',
popoverHasItems: true,
inputValue: '',
hasBadge: false,
hasCliBadge: false,
});
assert.equal(action.type, 'close_popover');
});
it('Escape in CLI popover returns close_popover', () => {
const action = resolveKeyAction('Escape', {
popoverMode: 'cli',
popoverHasItems: true,
inputValue: '',
hasBadge: false,
hasCliBadge: false,
});
assert.equal(action.type, 'close_popover');
});
});
// --- 2. Badge Behavior -----------------------------------------------
describe('Badge dispatch by kind', () => {
describe('agent_skill', () => {
const badge: CommandBadge = {
command: '/git-commit',
label: 'git-commit',
description: 'Smart git commit',
kind: 'agent_skill',
};
it('includes skill name and user context when context provided', () => {
const result = dispatchBadge(badge, 'fix the README');
assert.ok(result.prompt.includes('git-commit'));
assert.ok(result.prompt.includes('fix the README'));
});
it('sends default trigger when no user content', () => {
const result = dispatchBadge(badge, '');
assert.equal(result.prompt, 'Please use the git-commit skill.');
});
it('display label starts with /', () => {
const result = dispatchBadge(badge, '');
assert.equal(result.displayLabel, '/git-commit');
});
});
describe('sdk_command', () => {
const badge: CommandBadge = {
command: '/compact',
label: 'compact',
description: 'Compress context',
kind: 'sdk_command',
};
it('sends command with context appended', () => {
const result = dispatchBadge(badge, 'keep 5 messages');
assert.equal(result.prompt, '/compact keep 5 messages');
});
it('sends just command when no context', () => {
const result = dispatchBadge(badge, '');
assert.equal(result.prompt, '/compact');
});
});
describe('slash_command', () => {
const badge: CommandBadge = {
command: '/custom-skill',
label: 'custom-skill',
description: 'A custom skill',
kind: 'slash_command',
};
it('sends command with context appended', () => {
const result = dispatchBadge(badge, 'do the thing');
assert.equal(result.prompt, '/custom-skill do the thing');
});
it('slash_command and sdk_command behave identically', () => {
const sdkBadge: CommandBadge = { ...badge, kind: 'sdk_command' };
const slashResult = dispatchBadge(badge, 'test');
const sdkResult = dispatchBadge(sdkBadge, 'test');
assert.equal(slashResult.prompt, sdkResult.prompt);
});
});
describe('codepilot_command', () => {
const badge: CommandBadge = {
command: '/doctor',
label: 'doctor',
description: 'Diagnose project health',
kind: 'codepilot_command',
};
it('expands via COMMAND_PROMPTS when no user content', () => {
const result = dispatchBadge(badge, '');
assert.equal(result.prompt, COMMAND_PROMPTS['/doctor']);
});
it('appends user context to expanded prompt', () => {
const result = dispatchBadge(badge, 'focus on deps');
assert.ok(result.prompt.startsWith(COMMAND_PROMPTS['/doctor']));
assert.ok(result.prompt.includes('User context: focus on deps'));
});
it('falls back to command string when no expansion exists', () => {
const unknownBadge: CommandBadge = {
command: '/unknown',
label: 'unknown',
description: '',
kind: 'codepilot_command',
};
const result = dispatchBadge(unknownBadge, '');
assert.equal(result.prompt, '/unknown');
});
});
});
describe('Badge removal via keyboard', () => {
it('Backspace on empty input with badge removes badge', () => {
const action = resolveKeyAction('Backspace', {
popoverMode: null,
popoverHasItems: false,
inputValue: '',
hasBadge: true,
hasCliBadge: false,
});
assert.equal(action.type, 'remove_badge');
});
it('Backspace on non-empty input does NOT remove badge', () => {
const action = resolveKeyAction('Backspace', {
popoverMode: null,
popoverHasItems: false,
inputValue: 'hello',
hasBadge: true,
hasCliBadge: false,
});
assert.equal(action.type, 'passthrough');
});
it('Escape removes badge when no popover is open', () => {
const action = resolveKeyAction('Escape', {
popoverMode: null,
popoverHasItems: false,
inputValue: 'anything',
hasBadge: true,
hasCliBadge: false,
});
assert.equal(action.type, 'remove_badge');
});
it('Escape in active popover closes popover, not badge', () => {
const action = resolveKeyAction('Escape', {
popoverMode: 'skill',
popoverHasItems: true,
inputValue: '',
hasBadge: true,
hasCliBadge: false,
});
assert.equal(action.type, 'close_popover');
});
});
// --- 3. Model Selector (using real DEFAULT_MODEL_OPTIONS) ------------
describe('Model selector option lookup (real DEFAULT_MODEL_OPTIONS)', () => {
it('DEFAULT_MODEL_OPTIONS contains expected models', () => {
const values = DEFAULT_MODEL_OPTIONS.map((m) => m.value);
assert.ok(values.includes('sonnet'), 'should include sonnet');
assert.ok(values.includes('opus'), 'should include opus');
assert.ok(values.includes('haiku'), 'should include haiku');
});
it('finds matching model option', () => {
const current = DEFAULT_MODEL_OPTIONS.find((m) => m.value === 'opus');
assert.ok(current);
assert.ok(current.label.length > 0);
});
it('falls back to first option when model not found', () => {
const current = DEFAULT_MODEL_OPTIONS.find((m) => m.value === 'nonexistent') || DEFAULT_MODEL_OPTIONS[0];
assert.equal(current.value, DEFAULT_MODEL_OPTIONS[0].value);
});
it('default model is sonnet when modelName is empty', () => {
const modelName = '';
const currentModelValue = modelName || 'sonnet';
assert.equal(currentModelValue, 'sonnet');
});
it('default model is sonnet when modelName is undefined', () => {
const modelName: string | undefined = undefined;
const currentModelValue = modelName || 'sonnet';
assert.equal(currentModelValue, 'sonnet');
});
});
// --- 4. Effort Selector ----------------------------------------------
describe('Effort selector visibility logic', () => {
interface ModelMeta {
value: string;
label: string;
supportsEffort?: boolean;
supportedEffortLevels?: string[];
}
it('visible when model has supportsEffort=true', () => {
const meta: ModelMeta = { value: 'opus', label: 'Opus', supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high'] };
assert.equal(meta.supportsEffort === true, true);
});
it('hidden when model does not have supportsEffort', () => {
const meta: ModelMeta = { value: 'sonnet', label: 'Sonnet' };
assert.equal(meta.supportsEffort === true, false);
});
it('hidden when supportsEffort is false', () => {
const meta: ModelMeta = { value: 'haiku', label: 'Haiku', supportsEffort: false };
assert.equal(meta.supportsEffort === true, false);
});
it('effort prop takes precedence over local state', () => {
const effortProp: string | undefined = 'low';
const localEffort = 'high';
const selectedEffort = effortProp ?? localEffort;
assert.equal(selectedEffort, 'low');
});
it('local state used when effort prop is undefined', () => {
const effortProp: string | undefined = undefined;
const localEffort = 'high';
const selectedEffort = effortProp ?? localEffort;
assert.equal(selectedEffort, 'high');
});
});
// --- 5. Submit Behavior ----------------------------------------------
describe('Submit gating logic', () => {
it('empty input with no badge and no files disables submit', () => {
assert.equal(
isSubmitEnabled({ inputValue: '', hasBadge: false, hasFiles: false, isStreaming: false, disabled: false }),
false,
);
});
it('whitespace-only input disables submit', () => {
assert.equal(
isSubmitEnabled({ inputValue: ' ', hasBadge: false, hasFiles: false, isStreaming: false, disabled: false }),
false,
);
});
it('non-empty input enables submit', () => {
assert.equal(
isSubmitEnabled({ inputValue: 'hello', hasBadge: false, hasFiles: false, isStreaming: false, disabled: false }),
true,
);
});
it('badge without input enables submit', () => {
assert.equal(
isSubmitEnabled({ inputValue: '', hasBadge: true, hasFiles: false, isStreaming: false, disabled: false }),
true,
);
});
it('files without input enables submit', () => {
assert.equal(
isSubmitEnabled({ inputValue: '', hasBadge: false, hasFiles: true, isStreaming: false, disabled: false }),
true,
);
});
it('streaming always enables submit (stop button)', () => {
assert.equal(
isSubmitEnabled({ inputValue: '', hasBadge: false, hasFiles: false, isStreaming: true, disabled: false }),
true,
);
});
it('disabled flag overrides everything', () => {
assert.equal(
isSubmitEnabled({ inputValue: 'hello', hasBadge: true, hasFiles: true, isStreaming: false, disabled: true }),
false,
);
});
it('disabled flag even overrides streaming', () => {
assert.equal(
isSubmitEnabled({ inputValue: '', hasBadge: false, hasFiles: false, isStreaming: true, disabled: true }),
false,
);
});
});
describe('Direct slash command typed in input', () => {
it('/help is dispatched as immediate command', () => {
const result = resolveDirectSlash('/help');
assert.equal(result.action, 'immediate_command');
assert.equal(result.commandValue, '/help');
});
it('/clear is dispatched as immediate command', () => {
const result = resolveDirectSlash('/clear');
assert.equal(result.action, 'immediate_command');
});
it('/compact becomes a badge (non-immediate)', () => {
const result = resolveDirectSlash('/compact');
assert.equal(result.action, 'set_badge');
assert.ok(result.badge);
assert.equal(result.badge.kind, 'sdk_command');
});
it('/doctor becomes a codepilot_command badge', () => {
const result = resolveDirectSlash('/doctor');
assert.equal(result.action, 'set_badge');
assert.ok(result.badge);
assert.equal(result.badge.kind, 'codepilot_command');
});
it('unknown /command becomes slash_command badge', () => {
const result = resolveDirectSlash('/my-custom-tool');
assert.equal(result.action, 'unknown_slash_badge');
assert.ok(result.badge);
assert.equal(result.badge.kind, 'slash_command');
assert.equal(result.badge.label, 'my-custom-tool');
});
it('bare "/" returns not_slash', () => {
const result = resolveDirectSlash('/');
assert.equal(result.action, 'not_slash');
});
it('non-slash content returns not_slash', () => {
const result = resolveDirectSlash('hello world');
assert.equal(result.action, 'not_slash');
});
});
// --- 6. CLI Badge ----------------------------------------------------
describe('CLI badge behavior', () => {
it('CLI append is generated when badge is set', () => {
const cliBadge: CliBadge = { id: 'jq', name: 'jq' };
const append = buildCliAppend(cliBadge);
assert.ok(append);
assert.ok(append.includes('jq'));
assert.ok(append.includes('CLI tool'));
});
it('CLI append is undefined when no badge', () => {
const append = buildCliAppend(null);
assert.equal(append, undefined);
});
it('Backspace on empty input removes CLI badge (badge has priority)', () => {
const action = resolveKeyAction('Backspace', {
popoverMode: null,
popoverHasItems: false,
inputValue: '',
hasBadge: true,
hasCliBadge: true,
});
assert.equal(action.type, 'remove_badge');
});
it('Backspace on empty input removes CLI badge when no command badge', () => {
const action = resolveKeyAction('Backspace', {
popoverMode: null,
popoverHasItems: false,
inputValue: '',
hasBadge: false,
hasCliBadge: true,
});
assert.equal(action.type, 'remove_cli_badge');
});
it('Escape removes CLI badge when no command badge and no popover', () => {
const action = resolveKeyAction('Escape', {
popoverMode: null,
popoverHasItems: false,
inputValue: '',
hasBadge: false,
hasCliBadge: true,
});
assert.equal(action.type, 'remove_cli_badge');
});
it('CLI badge combined with hasBadge enables submit', () => {
assert.equal(
isSubmitEnabled({ inputValue: '', hasBadge: true, hasFiles: false, isStreaming: false, disabled: false }),
true,
);
});
});
// --- Cross-cutting: full keyboard interaction scenarios ---------------
describe('Full keyboard interaction scenarios', () => {
it('typing /doc -> ArrowDown -> Enter selects /doctor badge', () => {
const trigger = detectPopoverTrigger('/doc', 4);
assert.ok(trigger);
assert.equal(trigger.mode, 'skill');
assert.equal(trigger.filter, 'doc');
const filtered = filterItems(BUILT_IN_COMMANDS, 'doc');
assert.ok(filtered.length > 0);
assert.ok(filtered.some((i) => i.label === 'doctor'));
const doctorIdx = filtered.findIndex((i) => i.label === 'doctor');
const item = filtered[doctorIdx];
const result = resolveItemSelection(item, 'skill', trigger.triggerPos, '/doc', 'doc');
assert.equal(result.action, 'set_badge');
assert.ok(result.badge);
assert.equal(result.badge.command, '/doctor');
assert.equal(result.badge.kind, 'codepilot_command');
});
it('typing /help -> Enter immediately triggers command', () => {
const trigger = detectPopoverTrigger('/help', 5);
assert.ok(trigger);
const filtered = filterItems(BUILT_IN_COMMANDS, 'help');
assert.ok(filtered.length >= 1);
const helpItem = filtered.find((i) => i.label === 'help')!;
assert.ok(helpItem);
const result = resolveItemSelection(helpItem, 'skill', trigger.triggerPos, '/help', 'help');
assert.equal(result.action, 'immediate_command');
assert.equal(result.commandValue, '/help');
});
it('badge set -> user types context -> submit dispatches correctly', () => {
const badge: CommandBadge = {
command: '/compact',
label: 'compact',
description: 'Compress conversation context',
kind: 'sdk_command',
};
const userContent = 'keep last 5 messages';
const result = dispatchBadge(badge, userContent);
assert.equal(result.prompt, '/compact keep last 5 messages');
assert.equal(result.displayLabel, '/compact');
});
it('badge set -> empty submit dispatches with no context', () => {
const badge: CommandBadge = {
command: '/review',
label: 'review',
description: 'Review code',
kind: 'sdk_command',
};
assert.equal(
isSubmitEnabled({ inputValue: '', hasBadge: true, hasFiles: false, isStreaming: false, disabled: false }),
true,
);
const result = dispatchBadge(badge, '');
assert.equal(result.prompt, '/review');
});
it('typing @file -> selecting inserts mention', () => {
const input = 'look at @inde';
const cursorPos = input.length;
const trigger = detectPopoverTrigger(input, cursorPos);
assert.ok(trigger);
assert.equal(trigger.mode, 'file');
assert.equal(trigger.filter, 'inde');
assert.equal(trigger.triggerPos, 8);
const fileItem: PopoverItem = { label: 'index.ts', value: 'src/index.ts' };
const result = resolveItemSelection(fileItem, 'file', trigger.triggerPos, input, 'inde');
assert.equal(result.action, 'insert_file_mention');
assert.equal(result.newInputValue, 'look at @src/index.ts ');
});
});
// --- Built-in commands data integrity (using real imports) ------------
describe('Built-in commands data integrity (real BUILT_IN_COMMANDS)', () => {
it('all immediate commands have no kind', () => {
const immediates = BUILT_IN_COMMANDS.filter((c) => c.immediate);
assert.ok(immediates.length > 0, 'there should be at least one immediate command');
for (const cmd of immediates) {
assert.equal(cmd.kind, undefined, `immediate command "${cmd.value}" should not have a kind`);
}
});
it('all non-immediate commands have a kind', () => {
const nonImmediates = BUILT_IN_COMMANDS.filter((c) => !c.immediate);
assert.ok(nonImmediates.length > 0, 'there should be at least one non-immediate command');
for (const cmd of nonImmediates) {
assert.ok(cmd.kind, `non-immediate command "${cmd.value}" should have a kind`);
}
});
it('all codepilot_commands have expansion prompts in COMMAND_PROMPTS', () => {
const codepilotCmds = BUILT_IN_COMMANDS.filter((c) => c.kind === 'codepilot_command');
assert.ok(codepilotCmds.length > 0, 'there should be at least one codepilot_command');
for (const cmd of codepilotCmds) {
assert.ok(
COMMAND_PROMPTS[cmd.value],
`codepilot_command "${cmd.value}" should have an expansion prompt`,
);
}
});
it('sdk_commands do NOT have expansion prompts', () => {
const sdkCmds = BUILT_IN_COMMANDS.filter((c) => c.kind === 'sdk_command');
assert.ok(sdkCmds.length > 0, 'there should be at least one sdk_command');
for (const cmd of sdkCmds) {
assert.equal(
COMMAND_PROMPTS[cmd.value],
undefined,
`sdk_command "${cmd.value}" should not have an expansion prompt`,
);
}
});
it('all labels are unique', () => {
const labels = BUILT_IN_COMMANDS.map((c) => c.label);
const unique = new Set(labels);
assert.equal(labels.length, unique.size, 'duplicate labels found');
});
it('all values are unique and start with /', () => {
const values = BUILT_IN_COMMANDS.map((c) => c.value);
const unique = new Set(values);
assert.equal(values.length, unique.size, 'duplicate values found');
for (const val of values) {
assert.ok(val.startsWith('/'), `value "${val}" should start with /`);
}
});
it('COMMAND_PROMPTS keys are a subset of codepilot_command values', () => {
const codepilotValues = new Set(
BUILT_IN_COMMANDS.filter((c) => c.kind === 'codepilot_command').map((c) => c.value),
);
for (const key of Object.keys(COMMAND_PROMPTS)) {
assert.ok(
codepilotValues.has(key),
`COMMAND_PROMPTS key "${key}" is not a codepilot_command in BUILT_IN_COMMANDS`,
);
}
});
it('all builtIn flags are true', () => {
for (const cmd of BUILT_IN_COMMANDS) {
assert.equal(cmd.builtIn, true, `command "${cmd.value}" should have builtIn=true`);
}
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { Suspense } from "react";
import { SpinnerGap } from "@phosphor-icons/react";
import { SpinnerGap } from "@/components/ui/icon";
import { BridgeLayout } from "@/components/bridge/BridgeLayout";
export default function BridgePage() {
+14 -9
View File
@@ -4,7 +4,8 @@ import { useEffect, useState, useRef, useCallback, use } from 'react';
import Link from 'next/link';
import type { Message, MessagesResponse, ChatSession } from '@/types';
import { ChatView } from '@/components/chat/ChatView';
import { SpinnerGap, PencilSimple } from "@phosphor-icons/react";
import { SpinnerGap, PencilSimple } from "@/components/ui/icon";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { usePanel } from '@/hooks/usePanel';
@@ -37,7 +38,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
const handleStartEditTitle = useCallback(() => {
setEditTitle(sessionTitle || t('chat.newConversation'));
setIsEditingTitle(true);
}, [sessionTitle]);
}, [sessionTitle, t]);
const handleSaveTitle = useCallback(async () => {
const trimmed = editTitle.trim();
@@ -120,7 +121,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
loadSession();
return () => { cancelled = true; };
}, [id, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen]);
}, [id, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen, t]);
useEffect(() => {
// Reset state when switching sessions
@@ -192,8 +193,10 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
<>
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-xs text-muted-foreground shrink-0 hover:text-foreground transition-colors cursor-pointer"
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground shrink-0 hover:text-foreground transition-colors h-auto p-0"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
onClick={() => {
if (sessionWorkingDir) {
@@ -210,7 +213,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
}}
>
{projectName}
</button>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs break-all">{sessionWorkingDir || projectName}</p>
@@ -239,12 +242,14 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
<h2 className="text-sm font-medium text-foreground/80 truncate">
{sessionTitle}
</h2>
<button
<Button
variant="ghost"
size="icon"
onClick={handleStartEditTitle}
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 p-0.5 rounded hover:bg-muted"
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 h-auto w-auto p-0.5"
>
<PencilSimple size={12} className="text-muted-foreground" />
</button>
</Button>
</div>
)}
</div>
+3 -5
View File
@@ -24,15 +24,15 @@ interface ToolResultInfo {
export default function NewChatPage() {
const router = useRouter();
const { setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const { setPendingApprovalSessionId } = usePanel();
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, setWorkingDir] = useState('');
const [mode, setMode] = useState('code');
const [workingDir] = useState('');
const [mode] = useState('code');
const [currentModel, setCurrentModel] = useState('sonnet');
const [currentProviderId, setCurrentProviderId] = useState('');
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
@@ -419,8 +419,6 @@ export default function NewChatPage() {
setCurrentModel(model);
}}
workingDirectory={workingDir}
mode={mode}
onModeChange={setMode}
effort={selectedEffort}
onEffortChange={setSelectedEffort}
/>
+1 -1
View File
@@ -28,7 +28,7 @@ import {
Plus,
MagnifyingGlass,
Copy,
} from "@phosphor-icons/react";
} from "@/components/ui/icon";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
+7 -6
View File
@@ -1,9 +1,10 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { PaintBrush, SortDescending, Funnel, SpinnerGap, Heart } from '@phosphor-icons/react';
import { PaintBrush, SortDescending, Funnel, SpinnerGap, Heart } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { GalleryGrid, type GalleryItem } from '@/components/gallery/GalleryGrid';
import { GalleryDetail } from '@/components/gallery/GalleryDetail';
import { useTranslation } from '@/hooks/useTranslation';
@@ -148,7 +149,7 @@ export default function GalleryPage() {
>
<Heart
size={14}
className={cn(favoritesOnly && 'text-red-500')}
className={cn(favoritesOnly && 'text-status-error-foreground')}
weight={favoritesOnly ? 'fill' : 'regular'}
/>
{t('gallery.favoritesOnly' as TranslationKey)}
@@ -186,20 +187,20 @@ export default function GalleryPage() {
<label className="text-xs text-muted-foreground">
{t('gallery.dateFrom' as TranslationKey)}
</label>
<input
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
className="h-7 w-auto px-2 text-xs"
/>
<label className="text-xs text-muted-foreground">
{t('gallery.dateTo' as TranslationKey)}
</label>
<input
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
className="h-7 w-auto px-2 text-xs"
/>
{(dateFrom || dateTo) && (
<Button
+51
View File
@@ -44,6 +44,23 @@
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* Status semantic colors */
--color-status-success: var(--status-success);
--color-status-success-foreground: var(--status-success-foreground);
--color-status-success-muted: var(--status-success-muted);
--color-status-success-border: var(--status-success-border);
--color-status-warning: var(--status-warning);
--color-status-warning-foreground: var(--status-warning-foreground);
--color-status-warning-muted: var(--status-warning-muted);
--color-status-warning-border: var(--status-warning-border);
--color-status-error: var(--status-error);
--color-status-error-foreground: var(--status-error-foreground);
--color-status-error-muted: var(--status-error-muted);
--color-status-error-border: var(--status-error-border);
--color-status-info: var(--status-info);
--color-status-info-foreground: var(--status-info-foreground);
--color-status-info-muted: var(--status-info-muted);
--color-status-info-border: var(--status-info-border);
}
:root {
@@ -81,6 +98,23 @@
--sidebar-ring: oklch(0.546 0.245 262.881);
--user-bubble: oklch(0.22 0.005 250);
--user-bubble-foreground: oklch(0.985 0.001 106.423);
/* Status semantic tokens */
--status-success: oklch(0.527 0.154 150.069);
--status-success-foreground: oklch(0.393 0.124 148.697);
--status-success-muted: oklch(0.527 0.154 150.069 / 10%);
--status-success-border: oklch(0.527 0.154 150.069 / 20%);
--status-warning: oklch(0.705 0.213 47.604);
--status-warning-foreground: oklch(0.646 0.222 41.116);
--status-warning-muted: oklch(0.705 0.213 47.604 / 10%);
--status-warning-border: oklch(0.705 0.213 47.604 / 20%);
--status-error: oklch(0.577 0.245 27.325);
--status-error-foreground: oklch(0.577 0.245 27.325);
--status-error-muted: oklch(0.577 0.245 27.325 / 10%);
--status-error-border: oklch(0.577 0.245 27.325 / 20%);
--status-info: oklch(0.546 0.245 262.881);
--status-info-foreground: oklch(0.546 0.245 262.881);
--status-info-muted: oklch(0.546 0.245 262.881 / 10%);
--status-info-border: oklch(0.546 0.245 262.881 / 20%);
}
.dark {
@@ -117,6 +151,23 @@
--sidebar-ring: oklch(0.623 0.214 259.815);
--user-bubble: oklch(0.90 0.003 106);
--user-bubble-foreground: oklch(0.147 0.004 49.25);
/* Status semantic tokens */
--status-success: oklch(0.527 0.154 150.069);
--status-success-foreground: oklch(0.448 0.133 148.697);
--status-success-muted: oklch(0.527 0.154 150.069 / 10%);
--status-success-border: oklch(0.527 0.154 150.069 / 20%);
--status-warning: oklch(0.705 0.213 47.604);
--status-warning-foreground: oklch(0.705 0.213 47.604);
--status-warning-muted: oklch(0.705 0.213 47.604 / 10%);
--status-warning-border: oklch(0.705 0.213 47.604 / 20%);
--status-error: oklch(0.704 0.191 22.216);
--status-error-foreground: oklch(0.704 0.191 22.216);
--status-error-muted: oklch(0.704 0.191 22.216 / 10%);
--status-error-border: oklch(0.704 0.191 22.216 / 20%);
--status-info: oklch(0.623 0.214 259.815);
--status-info-foreground: oklch(0.623 0.214 259.815);
--status-info-muted: oklch(0.623 0.214 259.815 / 10%);
--status-info-border: oklch(0.623 0.214 259.815 / 20%);
}
@layer base {
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { Suspense } from "react";
import { SpinnerGap } from "@phosphor-icons/react";
import { SpinnerGap } from "@/components/ui/icon";
import { SettingsLayout } from "@/components/settings/SettingsLayout";
export default function SettingsPage() {
+6 -5
View File
@@ -1,9 +1,9 @@
"use client";
import { useState, useCallback, useSyncExternalStore } from "react";
import { WifiHigh, TelegramLogo, ChatTeardrop, GameController, ChatsCircle } from "@phosphor-icons/react";
import type { Icon } from "@phosphor-icons/react";
import { WifiHigh, TelegramLogo, ChatTeardrop, GameController, ChatsCircle, type Icon } from "@/components/ui/icon";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { BridgeSection } from "./BridgeSection";
import { TelegramBridgeSection } from "./TelegramBridgeSection";
import { FeishuBridgeSection } from "./FeishuBridgeSection";
@@ -75,11 +75,12 @@ export function BridgeLayout() {
<div className="flex min-h-0 flex-1">
<nav className="flex w-52 shrink-0 flex-col gap-1 border-r border-border/50 p-3">
{sidebarItems.map((item) => (
<button
<Button
key={item.id}
variant="ghost"
onClick={() => handleSectionChange(item.id)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left",
"justify-start gap-3 px-3 py-2 text-sm font-medium text-left w-full",
activeSection === item.id
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
@@ -87,7 +88,7 @@ export function BridgeLayout() {
>
<item.icon size={16} className="shrink-0" />
{t(bridgeLabelKeys[item.label])}
</button>
</Button>
))}
</nav>
+4 -4
View File
@@ -13,7 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SpinnerGap, CheckCircle, Warning, TelegramLogo, ChatTeardrop, GameController, ChatsCircle } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, Warning, TelegramLogo, ChatTeardrop, GameController, ChatsCircle } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { useBridgeStatus } from "@/hooks/useBridgeStatus";
import { SettingsCard } from "@/components/patterns/SettingsCard";
@@ -215,7 +215,7 @@ export function BridgeSection() {
<div
className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-xs ${
isRunning
? "bg-green-500/10 text-green-600 dark:text-green-400"
? "bg-status-success-muted text-status-success-foreground"
: "bg-muted text-muted-foreground"
}`}
>
@@ -360,7 +360,7 @@ export function BridgeSection() {
<div
className={`rounded px-2 py-0.5 text-xs ${
adapter.running
? "bg-green-500/10 text-green-600 dark:text-green-400"
? "bg-status-success-muted text-status-success-foreground"
: "bg-muted text-muted-foreground"
}`}
>
@@ -375,7 +375,7 @@ export function BridgeSection() {
</p>
)}
{adapter.error && (
<p className="text-xs text-red-500">
<p className="text-xs text-status-error-foreground">
{t("bridge.adapterLastError")}: {adapter.error}
</p>
)}
@@ -11,7 +11,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { SpinnerGap, CheckCircle, Warning } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, Warning } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { SettingsCard } from "@/components/patterns/SettingsCard";
import { FieldRow } from "@/components/patterns/FieldRow";
@@ -11,7 +11,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { SpinnerGap, CheckCircle, Warning } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, Warning } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { SettingsCard } from "@/components/patterns/SettingsCard";
import { FieldRow } from "@/components/patterns/FieldRow";
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { SpinnerGap, CheckCircle, Warning } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, Warning } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { SettingsCard } from "@/components/patterns/SettingsCard";
import { FieldRow } from "@/components/patterns/FieldRow";
@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SpinnerGap, CheckCircle, Warning } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, Warning } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { SettingsCard } from "@/components/patterns/SettingsCard";
import { StatusBanner } from "@/components/patterns/StatusBanner";
@@ -21,7 +21,7 @@ const DEFAULT_SETTINGS: TelegramBridgeSettings = {
};
export function TelegramBridgeSection() {
const [settings, setSettings] = useState<TelegramBridgeSettings>(DEFAULT_SETTINGS);
const [, setSettings] = useState<TelegramBridgeSettings>(DEFAULT_SETTINGS);
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
const [allowedUsers, setAllowedUsers] = useState("");
+10 -8
View File
@@ -18,7 +18,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Lock, LockOpen, CaretDown } from '@phosphor-icons/react';
import { Button } from '@/components/ui/button';
import { Lock, LockOpen, CaretDown } from '@/components/ui/icon';
interface ChatPermissionSelectorProps {
sessionId?: string;
@@ -70,16 +71,17 @@ export function ChatPermissionSelector({
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
<Button
variant="ghost"
size="sm"
className={`gap-1 px-2 py-1 text-xs font-medium ${
isFullAccess
? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20'
? 'bg-status-error-muted text-status-error-foreground hover:bg-status-error-muted'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{isFullAccess ? (
<LockOpen size={14} className="text-red-500" />
<LockOpen size={14} className="text-status-error-foreground" />
) : (
<Lock size={14} />
)}
@@ -87,7 +89,7 @@ export function ChatPermissionSelector({
{isFullAccess ? t('permission.fullAccess') : t('permission.default')}
</span>
<CaretDown size={10} className="opacity-60" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[140px]">
<DropdownMenuItem onClick={() => handleSelect('default')}>
@@ -95,7 +97,7 @@ export function ChatPermissionSelector({
<span>{t('permission.default')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSelect('full_access')}>
<LockOpen size={14} className="text-red-500" />
<LockOpen size={14} className="text-status-error-foreground" />
<span>{t('permission.fullAccess')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
+57 -387
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types';
import { MessageList } from './MessageList';
@@ -9,20 +9,21 @@ import { ChatComposerActionBar } from './ChatComposerActionBar';
import { ChatPermissionSelector } from './ChatPermissionSelector';
import { ContextUsageIndicator } from './ContextUsageIndicator';
import { ImageGenToggle } from './ImageGenToggle';
import { Button } from '@/components/ui/button';
import { usePanel } from '@/hooks/usePanel';
import { getLocalDateString } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { PermissionPrompt } from './PermissionPrompt';
import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen';
import { setLastGeneratedImages, transferPendingToMessage } from '@/lib/image-ref-store';
import { setLastGeneratedImages } from '@/lib/image-ref-store';
import { useChatCommands } from '@/hooks/useChatCommands';
import { useAssistantTrigger } from '@/hooks/useAssistantTrigger';
import { useStreamSubscription } from '@/hooks/useStreamSubscription';
import {
startStream,
stopStream,
subscribe,
getSnapshot,
getRewindPoints,
respondToPermission,
clearSnapshot,
} from '@/lib/stream-session-manager';
interface ChatViewProps {
@@ -36,7 +37,7 @@ interface ChatViewProps {
}
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId, initialPermissionProfile }: ChatViewProps) {
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const { setStreamingSessionId, workingDirectory, setPendingApprovalSessionId } = usePanel();
const { t } = useTranslation();
const router = useRouter();
const [messages, setMessages] = useState<Message[]>(initialMessages);
@@ -48,55 +49,30 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const [loadingMore, setLoadingMore] = useState(false);
const loadingMoreRef = useRef(false);
const [mode, setMode] = useState(initialMode || 'code');
// Parent components (page.tsx, SplitColumn) guarantee that session metadata is
// loaded before ChatView mounts, so modelName/providerId reflect the session's
// actual values. Fall back to localStorage only when genuinely empty (old/imported
// sessions that never had a model saved).
const [currentModel, setCurrentModel] = useState(() => modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
const [currentProviderId, setCurrentProviderId] = useState(() => providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
// Effort level selected in MessageInput — lifted here so it enters the stream chain
const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined);
// Thinking mode from app settings
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
// Sync model/provider when session data loads (props update after async fetch).
// Only override when the session actually has a saved model/provider — empty string
// means "not yet loaded" or "old session without model"; in that case keep current
// value (which was properly initialized from localStorage on mount).
useEffect(() => {
if (modelName) {
setCurrentModel(modelName);
}
}, [modelName]);
useEffect(() => {
if (providerId) {
setCurrentProviderId(providerId);
}
}, [providerId]);
// Sync model/provider when session data loads
useEffect(() => { if (modelName) setCurrentModel(modelName); }, [modelName]);
useEffect(() => { if (providerId) setCurrentProviderId(providerId); }, [providerId]);
// Fetch thinking mode from app settings
useEffect(() => {
fetch('/api/settings/app')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.settings?.thinking_mode) {
setThinkingMode(data.settings.thinking_mode);
}
})
.then(data => { if (data?.settings?.thinking_mode) setThinkingMode(data.settings.thinking_mode); })
.catch(() => {});
}, []);
useEffect(() => {
if (initialPermissionProfile) {
setPermissionProfile(initialPermissionProfile);
}
}, [initialPermissionProfile]);
useEffect(() => { if (initialPermissionProfile) setPermissionProfile(initialPermissionProfile); }, [initialPermissionProfile]);
// Stream snapshot from the manager — drives all streaming UI
const [streamSnapshot, setStreamSnapshot] = useState<SessionStreamSnapshot | null>(
() => getSnapshot(sessionId)
);
// Derive rendering state from snapshot (backward-compatible with MessageList props)
// Derive rendering state from snapshot
const isStreaming = streamSnapshot?.phase === 'active';
const streamingContent = streamSnapshot?.streamingContent ?? '';
const toolUses = streamSnapshot?.toolUses ?? [];
@@ -107,16 +83,13 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const permissionResolved = streamSnapshot?.permissionResolved ?? null;
const rewindPoints = getRewindPoints(sessionId);
// Pending image generation notices — flushed into the next user message so the LLM knows about generated images
// Pending image generation notices
const pendingImageNoticesRef = useRef<string[]>([]);
// Ref for sendMessage to allow self-referencing in timeout auto-retry
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
// Init metadata from SDK (tools, slash_commands, skills) — populated on first status event
const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
// Persist mode to database and notify chat list
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
@@ -126,19 +99,17 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
window.dispatchEvent(new CustomEvent('session-updated'));
}).catch(() => { /* silent */ });
// Try to switch SDK permission mode in real-time (works if streaming)
fetch('/api/chat/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, mode: newMode }),
}).catch(() => { /* silent — will apply on next message */ });
}).catch(() => { /* silent */ });
}
}, [sessionId]);
const handleProviderModelChange = useCallback((newProviderId: string, model: string) => {
setCurrentProviderId(newProviderId);
setCurrentModel(model);
// Persist immediately so switching chats preserves the selection
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -146,97 +117,15 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}).catch(() => {});
}, [sessionId]);
// Subscribe to stream-session-manager for this session.
// On unmount we only unsubscribe — we do NOT abort the stream.
useEffect(() => {
// Restore snapshot if stream is already active (e.g., user switched away and back)
const existing = getSnapshot(sessionId);
if (existing) {
setStreamSnapshot(existing);
if (existing.phase === 'active') {
setStreamingSessionId(sessionId);
}
if (existing.pendingPermission && !existing.permissionResolved) {
setPendingApprovalSessionId(sessionId);
}
// If stream completed while this ChatView was unmounted, consume finalMessageContent now.
// Re-fetch messages from DB to avoid duplicates (backend already persisted the reply).
if (existing.phase !== 'active' && existing.finalMessageContent) {
// Completion processing (onboarding/checkin) is handled server-side only
// in the chat API's finally block — no frontend duplicate needed.
fetch(`/api/chat/sessions/${sessionId}/messages?limit=50`)
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.messages) {
setMessages(data.messages);
}
})
.catch(() => {
// Fallback: append locally if DB fetch fails
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: existing.finalMessageContent!,
created_at: new Date().toISOString(),
token_usage: existing.tokenUsage ? JSON.stringify(existing.tokenUsage) : null,
};
transferPendingToMessage(assistantMessage.id);
setMessages((prev) => [...prev, assistantMessage]);
});
clearSnapshot(sessionId);
}
} else {
setStreamSnapshot(null);
}
// ── Extracted hooks ──
const unsubscribe = subscribe(sessionId, (event) => {
setStreamSnapshot(event.snapshot);
// Sync panel state
if (event.type === 'phase-changed') {
if (event.snapshot.phase === 'active') {
setStreamingSessionId(sessionId);
} else {
setStreamingSessionId('');
setPendingApprovalSessionId('');
}
}
if (event.type === 'permission-request') {
setPendingApprovalSessionId(sessionId);
}
if (event.type === 'completed') {
setStreamingSessionId('');
setPendingApprovalSessionId('');
// Append the final assistant message to the messages list
const finalContent = event.snapshot.finalMessageContent;
if (finalContent) {
// Completion processing (onboarding/checkin) is handled server-side only.
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: finalContent,
created_at: new Date().toISOString(),
token_usage: event.snapshot.tokenUsage ? JSON.stringify(event.snapshot.tokenUsage) : null,
};
// Transfer pending reference images to this message ID
transferPendingToMessage(assistantMessage.id);
setMessages((prev) => [...prev, assistantMessage]);
}
// Clear the snapshot from the manager since we've consumed it
clearSnapshot(sessionId);
}
});
return () => {
unsubscribe();
// Do NOT abort — stream continues in the manager
};
}, [sessionId, setStreamingSessionId, setPendingApprovalSessionId]);
useStreamSubscription({
sessionId,
setStreamSnapshot,
setStreamingSessionId,
setPendingApprovalSessionId,
setMessages,
});
const initializedRef = useRef(false);
useEffect(() => {
@@ -246,142 +135,31 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}
}, [initialMessages]);
// Sync mode when session data loads
useEffect(() => {
if (initialMode) {
setMode(initialMode);
}
}, [initialMode]);
useEffect(() => { if (initialMode) setMode(initialMode); }, [initialMode]);
useEffect(() => { setHasMore(initialHasMore); }, [initialHasMore]);
// Sync hasMore when initial data loads
useEffect(() => {
setHasMore(initialHasMore);
}, [initialHasMore]);
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
if (thinkingMode === 'enabled') return { type: 'enabled' };
if (thinkingMode === 'disabled') return { type: 'disabled' };
return undefined;
}, [thinkingMode]);
// Auto-trigger assistant project hooks (onboarding/check-in)
// Uses autoTrigger flag so the backend skips saving user message and title update.
// Works for both fresh sessions (onboarding) and reused sessions (check-in).
const assistantTriggerFiredRef = useRef(false);
const checkAssistantTrigger = useCallback(async () => {
// Don't trigger if already streaming or already triggered in this mount
if (isStreaming || assistantTriggerFiredRef.current) return;
const checkAssistantTrigger = useAssistantTrigger({
sessionId,
workingDirectory,
isStreaming,
mode,
currentModel,
currentProviderId,
initialMessages,
handleModeChange,
buildThinkingConfig,
sendMessageRef,
initMetaRef,
});
try {
const res = await fetch('/api/settings/workspace');
if (!res.ok) return;
const data = await res.json();
if (!data.path) return;
// Check if this session's working directory matches workspace path
if (workingDirectory !== data.path) return;
const state = data.state;
if (!state) return;
// Check hookTriggeredSessionId: if this session already has a trigger in progress
// AND there are existing messages (conversation started), skip to avoid re-triggering.
// If the session has no messages, the previous trigger may have failed — allow retry.
if (state.hookTriggeredSessionId === sessionId && initialMessages.length > 0) return;
const today = getLocalDateString();
const needsOnboarding = !state.onboardingComplete;
const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today;
if (!needsOnboarding && !needsCheckIn) return;
// ── Compensation: check if a past message already contains a completion fence ──
// This handles the case where the server-side detection also missed (e.g. crash/restart)
// and the frontend is about to re-trigger onboarding unnecessarily.
if (needsOnboarding && initialMessages.length > 0) {
try {
const { extractCompletion } = await import('@/lib/onboarding-completion');
// Scan assistant messages from newest to oldest for an unprocessed completion
for (let i = initialMessages.length - 1; i >= 0; i--) {
const msg = initialMessages[i];
if (msg.role !== 'assistant') continue;
const completion = extractCompletion(msg.content);
if (completion?.type === 'onboarding') {
console.log('[ChatView] Found unprocessed onboarding completion in message history, compensating...');
const resp = await fetch('/api/workspace/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: completion.answers, sessionId }),
});
if (resp.ok) {
await fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: '__clear__' }),
}).catch(() => {});
console.log('[ChatView] Onboarding compensation succeeded, skipping re-trigger');
return; // Don't re-trigger onboarding
}
break; // Found fence but processing failed — fall through to re-trigger
}
}
} catch (e) {
console.error('[ChatView] Onboarding compensation check failed:', e);
}
}
// For daily check-in, only trigger in the most recent session for this workspace.
// This prevents older sessions from hijacking the check-in when reopened.
if (needsCheckIn) {
const latestRes = await fetch(`/api/workspace/latest-session?workingDirectory=${encodeURIComponent(data.path)}`);
if (latestRes.ok) {
const { sessionId: latestSessionId } = await latestRes.json();
if (latestSessionId && latestSessionId !== sessionId) return;
}
}
// Mark fired so we don't re-trigger on focus/re-render
assistantTriggerFiredRef.current = true;
// Mark in persistent state to prevent duplicate triggers across page reloads
await fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
// Use autoTrigger: the message is invisible (no user bubble, no title update)
const triggerMsg = needsOnboarding
? '请开始助理引导设置。'
: '请开始每日问询。';
startStream({
sessionId,
content: triggerMsg,
mode,
model: currentModel,
providerId: currentProviderId,
autoTrigger: true,
thinking: buildThinkingConfig(),
onModeChanged: (sdkMode) => {
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
},
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
sendMessageRef.current?.(retryContent, retryFiles);
},
onInitMeta: (meta) => {
initMetaRef.current = meta;
console.log('[ChatView] SDK init meta received:', meta);
},
});
} catch (e) {
console.error('[ChatView] Assistant auto-trigger failed:', e);
}
}, [sessionId, workingDirectory, isStreaming, mode, currentModel, currentProviderId, handleModeChange]);
useEffect(() => {
// Small delay to let the session fully initialize
const timer = setTimeout(checkAssistantTrigger, 500);
return () => clearTimeout(timer);
}, [checkAssistantTrigger]);
// Detect workspace mismatch: only show banner when this session was previously
// an assistant workspace session (has .assistant data) but the path has since changed.
// Normal project chats should never see this banner.
// Detect workspace mismatch
useEffect(() => {
if (!workingDirectory) return;
let cancelled = false;
@@ -393,8 +171,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
if (cancelled) return;
if (data.path && workingDirectory !== data.path) {
// Only show banner if this session's workingDirectory was itself an assistant workspace
// (i.e., it has .assistant/state.json). Regular project chats skip the banner.
const inspectRes = await fetch(`/api/workspace/inspect?path=${encodeURIComponent(workingDirectory)}`);
if (!inspectRes.ok || cancelled) return;
const inspectData = await inspectRes.json();
@@ -413,8 +189,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
return () => { cancelled = true; };
}, [workingDirectory]);
// Listen for workspace-switched events (from AssistantWorkspaceSection).
// Only show banner if this session's workingDirectory was the OLD assistant workspace.
// Listen for workspace-switched events
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
@@ -430,7 +205,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
try {
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
// Prefer reusing the latest assistant session (checkin mode) rather than always creating a new onboarding session
const res = await fetch('/api/workspace/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -447,12 +221,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}, [router]);
const loadEarlierMessages = useCallback(async () => {
// Use ref as atomic lock to prevent double-fetch from rapid clicks
if (loadingMoreRef.current || !hasMore || messages.length === 0) return;
loadingMoreRef.current = true;
setLoadingMore(true);
try {
// Use _rowid of the earliest message as cursor
const earliest = messages[0];
const earliestRowId = (earliest as Message & { _rowid?: number })._rowid;
if (!earliestRowId) return;
@@ -469,12 +241,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}
}, [sessionId, messages, hasMore]);
// Stop streaming — delegates to manager
const stopStreaming = useCallback(() => {
stopStream(sessionId);
}, [sessionId]);
const stopStreaming = useCallback(() => { stopStream(sessionId); }, [sessionId]);
// Permission response — delegates to manager
const handlePermissionResponse = useCallback(
async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, denyMessage?: string) => {
setPendingApprovalSessionId('');
@@ -483,30 +251,17 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
[sessionId, setPendingApprovalSessionId]
);
// Build SDK thinking config from settings
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
if (thinkingMode === 'enabled') return { type: 'enabled' };
if (thinkingMode === 'disabled') return { type: 'disabled' };
return undefined;
}, [thinkingMode]);
// Send message — delegates stream management to the manager
const sendMessage = useCallback(
async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => {
if (isStreaming) return;
// Use displayOverride for UI if provided (e.g. image-gen skill injection hides the skill prompt)
const displayUserContent = displayOverride || content;
// Build display content: embed file metadata as HTML comment for MessageItem to parse
let displayContent = displayUserContent;
if (files && files.length > 0) {
const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size }));
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${displayUserContent}`;
}
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
@@ -517,15 +272,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
};
setMessages((prev) => [...prev, userMessage]);
// Flush pending image notices
const notices = pendingImageNoticesRef.current.length > 0
? [...pendingImageNoticesRef.current]
: undefined;
if (notices) {
pendingImageNoticesRef.current = [];
}
if (notices) pendingImageNoticesRef.current = [];
// Delegate to stream session manager
startStream({
sessionId,
content,
@@ -554,87 +305,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
[sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange]
);
// Keep sendMessageRef in sync so timeout auto-retry can call it
sendMessageRef.current = sendMessage;
const handleCommand = useCallback((command: string) => {
switch (command) {
case '/help': {
const helpMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Available Commands\n\n### Instant Commands\n- **/help** — Show this help message\n- **/clear** — Clear conversation history\n- **/cost** — Show token usage statistics\n\n### Prompt Commands (shown as badge, add context then send)\n- **/compact** — Compress conversation context\n- **/doctor** — Diagnose project health\n- **/init** — Initialize CLAUDE.md for project\n- **/review** — Review code quality\n- **/terminal-setup** — Configure terminal settings\n- **/memory** — Edit project memory file\n\n### Custom Skills\nSkills from \`~/.claude/commands/\` and project \`.claude/commands/\` are also available via \`/\`.\n\n**Tips:**\n- Type \`/\` to browse commands and skills\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, helpMessage]);
break;
}
case '/clear':
setMessages([]);
// Also clear database messages and reset SDK session
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear_messages: true }),
}).catch(() => { /* silent */ });
}
break;
case '/cost': {
// Aggregate token usage from all messages in this session
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheCreation = 0;
let totalCost = 0;
let turnCount = 0;
const handleCommand = useChatCommands({ sessionId, messages, setMessages, sendMessage });
for (const msg of messages) {
if (msg.token_usage) {
try {
const usage = typeof msg.token_usage === 'string' ? JSON.parse(msg.token_usage) : msg.token_usage;
totalInput += usage.input_tokens || 0;
totalOutput += usage.output_tokens || 0;
totalCacheRead += usage.cache_read_input_tokens || 0;
totalCacheCreation += usage.cache_creation_input_tokens || 0;
if (usage.cost_usd) totalCost += usage.cost_usd;
turnCount++;
} catch { /* skip */ }
}
}
const totalTokens = totalInput + totalOutput;
let content: string;
if (turnCount === 0) {
content = `## Token Usage\n\nNo token usage data yet. Send a message first.`;
} else {
content = `## Token Usage\n\n| Metric | Count |\n|--------|-------|\n| Input tokens | ${totalInput.toLocaleString()} |\n| Output tokens | ${totalOutput.toLocaleString()} |\n| Cache read | ${totalCacheRead.toLocaleString()} |\n| Cache creation | ${totalCacheCreation.toLocaleString()} |\n| **Total tokens** | **${totalTokens.toLocaleString()}** |\n| Turns | ${turnCount} |${totalCost > 0 ? `\n| **Estimated cost** | **$${totalCost.toFixed(4)}** |` : ''}`;
}
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
// This shouldn't be reached since non-immediate commands are handled via badge
sendMessage(command);
}
}, [sessionId, sendMessage]);
// Listen for image generation completion — persist notice to DB and queue for next user message.
// The notice is NOT sent as a separate LLM turn (avoids permission popups).
// Instead it's flushed into the next user message via pendingImageNoticesRef.
// MessageItem hides messages matching this prefix so the user doesn't see them.
// Listen for image generation completion
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
@@ -645,15 +320,12 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const pathInfo = paths.length > 0 ? `\nGenerated image file paths:\n${paths.map((p: string) => `- ${p}`).join('\n')}` : '';
const notice = `[Image generation completed]\n- Prompt: "${detail.prompt}"\n- Aspect ratio: ${detail.aspectRatio}\n- Resolution: ${detail.resolution}${pathInfo}`;
// Store generated image paths so subsequent edits can use them as reference
if (paths.length > 0) {
setLastGeneratedImages(paths);
}
// Queue for next user message so the LLM gets the context
pendingImageNoticesRef.current.push(notice);
// Also persist to DB for history reload
const dbNotice = `[__IMAGE_GEN_NOTICE__ prompt: "${detail.prompt}", aspect ratio: ${detail.aspectRatio}, resolution: ${detail.resolution}${paths.length > 0 ? `, file path: ${paths.join(', ')}` : ''}]`;
fetch('/api/chat/messages', {
method: 'POST',
@@ -669,16 +341,16 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
<div className="flex h-full min-h-0 flex-col">
{/* Workspace mismatch banner */}
{workspaceMismatchPath && (
<div className="flex items-center justify-between gap-3 border-b border-yellow-500/30 bg-yellow-500/10 px-4 py-2">
<span className="text-xs text-yellow-700 dark:text-yellow-400">
<div className="flex items-center justify-between gap-3 border-b border-status-warning/30 bg-status-warning-muted px-4 py-2">
<span className="text-xs text-status-warning-foreground">
{t('assistant.switchedBanner', { path: workspaceMismatchPath })}
</span>
<button
<Button
onClick={handleOpenNewAssistant}
className="shrink-0 rounded-md bg-yellow-600 px-3 py-1 text-xs font-medium text-white hover:bg-yellow-700 transition-colors"
className="shrink-0 rounded-md bg-status-warning px-3 py-1 text-xs font-medium text-white hover:bg-status-warning/80 transition-colors"
>
{t('assistant.openNewAssistant')}
</button>
</Button>
</div>
)}
<MessageList
@@ -696,7 +368,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
rewindPoints={rewindPoints}
sessionId={sessionId}
/>
{/* Permission prompt — rendered outside MessageList so it's always visible at bottom */}
{/* Permission prompt */}
<PermissionPrompt
pendingPermission={pendingPermission}
permissionResolved={permissionResolved}
@@ -704,7 +376,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
toolUses={toolUses}
permissionProfile={permissionProfile}
/>
{/* Batch image generation panels — shown above the input area */}
{/* Batch image generation panels */}
<BatchExecutionDashboard />
<BatchContextSync />
@@ -721,8 +393,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
providerId={currentProviderId}
onProviderModelChange={handleProviderModelChange}
workingDirectory={workingDirectory}
mode={mode}
onModeChange={handleModeChange}
onAssistantTrigger={checkAssistantTrigger}
effort={selectedEffort}
onEffortChange={setSelectedEffort}
+56 -64
View File
@@ -1,17 +1,21 @@
'use client';
import { useCallback } from 'react';
import { Terminal } from '@phosphor-icons/react';
import { cn } from '@/lib/utils';
import { Terminal } from '@/components/ui/icon';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { CliToolItem } from '@/types';
import {
CommandList,
CommandListSearch,
CommandListItems,
CommandListItem,
CommandListEmpty,
CommandListFooter,
CommandListFooterAction,
} from '@/components/patterns';
export interface CliToolItem {
id: string;
name: string;
version: string | null;
summary: string;
}
export type { CliToolItem } from '@/types';
interface CliToolsPopoverProps {
popoverRef: React.RefObject<HTMLDivElement | null>;
@@ -63,66 +67,54 @@ export function CliToolsPopover({
}, [selectedIndex, filtered, onSetSelectedIndex, onCliSelect, onClosePopover, onFocusTextarea]);
return (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-full max-w-2xl rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
>
<div className="px-3 py-2 border-b">
<input
ref={cliSearchRef}
type="text"
<div ref={popoverRef}>
<CommandList className="w-full max-w-2xl">
<CommandListSearch
inputRef={cliSearchRef}
placeholder={t('cliTools.searchPlaceholder' as TranslationKey)}
value={cliFilter}
onChange={(e) => { onSetCliFilter(e.target.value); onSetSelectedIndex(0); }}
onChange={(val) => { onSetCliFilter(val); onSetSelectedIndex(0); }}
onKeyDown={handleSearchKeyDown}
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="max-h-48 overflow-y-auto py-1">
{filtered.length > 0 ? (
filtered.map((tool, idx) => (
<button
key={tool.id}
ref={idx === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
idx === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => onCliSelect(tool)}
onMouseEnter={() => onSetSelectedIndex(idx)}
>
<Terminal size={16} className="shrink-0 text-muted-foreground" />
<span className="font-medium text-xs truncate">{tool.name}</span>
{tool.version && (
<span className="text-[10px] text-muted-foreground shrink-0">v{tool.version}</span>
)}
{tool.summary && (
<span className="text-xs text-muted-foreground truncate ml-auto max-w-[200px]">{tool.summary}</span>
)}
</button>
))
) : (
<div className="px-3 py-4 text-center">
<p className="text-sm text-muted-foreground">{t('cliTools.noToolsDetected' as TranslationKey)}</p>
<button
className="mt-2 text-xs text-primary hover:underline"
onClick={() => { onClosePopover(); window.location.href = '/cli-tools'; }}
>
{t('cliTools.goInstall' as TranslationKey)}
</button>
</div>
)}
</div>
{/* Footer: manage CLI tools */}
<div className="border-t px-3 py-1.5">
<button
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
onClick={() => { onClosePopover(); window.location.href = '/cli-tools'; }}
>
<Terminal size={14} />
{t('cliTools.manageCli' as TranslationKey)}
</button>
</div>
<CommandListItems className="max-h-48">
{filtered.length > 0 ? (
filtered.map((tool, idx) => (
<CommandListItem
key={tool.id}
active={idx === selectedIndex}
itemRef={idx === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
onClick={() => onCliSelect(tool)}
onMouseEnter={() => onSetSelectedIndex(idx)}
>
<Terminal size={16} className="shrink-0 text-muted-foreground" />
<span className="font-medium text-xs truncate">{tool.name}</span>
{tool.version && (
<span className="text-[10px] text-muted-foreground shrink-0">v{tool.version}</span>
)}
{tool.summary && (
<span className="text-xs text-muted-foreground truncate ml-auto max-w-[200px]">{tool.summary}</span>
)}
</CommandListItem>
))
) : (
<CommandListEmpty>
<p className="text-sm text-muted-foreground">{t('cliTools.noToolsDetected' as TranslationKey)}</p>
<CommandListFooterAction onClick={() => { onClosePopover(); window.location.href = '/cli-tools'; }}>
<span className="mt-2 text-xs text-primary hover:underline">
{t('cliTools.goInstall' as TranslationKey)}
</span>
</CommandListFooterAction>
</CommandListEmpty>
)}
</CommandListItems>
{/* Footer: manage CLI tools */}
<CommandListFooter>
<CommandListFooterAction onClick={() => { onClosePopover(); window.location.href = '/cli-tools'; }}>
<Terminal size={14} />
{t('cliTools.manageCli' as TranslationKey)}
</CommandListFooterAction>
</CommandListFooter>
</CommandList>
</div>
);
}
@@ -3,6 +3,7 @@
import type { Message } from '@/types';
import { useContextUsage } from '@/hooks/useContextUsage';
import { useTranslation } from '@/hooks/useTranslation';
import { Button } from '@/components/ui/button';
import {
HoverCard,
HoverCardTrigger,
@@ -32,15 +33,15 @@ export function ContextUsageIndicator({ messages, modelName }: ContextUsageIndic
// Color based on usage ratio — used portion is dark gray by default
let strokeColor = 'text-muted-foreground';
if (usage.hasData) {
if (usage.ratio > 0.8) strokeColor = 'text-red-500';
else if (usage.ratio > 0.6) strokeColor = 'text-yellow-500';
if (usage.ratio > 0.8) strokeColor = 'text-status-error-foreground';
else if (usage.ratio > 0.6) strokeColor = 'text-status-warning-foreground';
else strokeColor = 'text-zinc-600 dark:text-zinc-400';
}
return (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button className="flex items-center justify-center p-1 rounded hover:bg-muted transition-colors" type="button">
<Button variant="ghost" size="icon-xs" className="p-1">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
{/* Background circle */}
<circle
@@ -68,7 +69,7 @@ export function ContextUsageIndicator({ messages, modelName }: ContextUsageIndic
/>
)}
</svg>
</button>
</Button>
</HoverCardTrigger>
<HoverCardContent side="top" align="center" className="w-56 p-3 text-xs">
{!usage.hasData ? (
+26 -24
View File
@@ -1,11 +1,16 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { CaretDown } from '@phosphor-icons/react';
import { CaretDown } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { PromptInputButton } from '@/components/ai-elements/prompt-input';
import {
CommandList,
CommandListItem,
CommandListGroup,
} from '@/components/patterns';
interface EffortSelectorDropdownProps {
selectedEffort: string;
@@ -46,29 +51,26 @@ export function EffortSelectorDropdown({
</PromptInputButton>
{effortMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-36 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground">
{t('messageInput.effort.label' as TranslationKey)}
</div>
<div className="py-0.5">
{levels.map((level) => (
<button
key={level}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
selectedEffort === level ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onEffortChange(level);
setEffortMenuOpen(false);
}}
>
<span className="text-xs">{t(`messageInput.effort.${level}` as TranslationKey)}</span>
{selectedEffort === level && <span className="text-xs">&#10003;</span>}
</button>
))}
</div>
</div>
<CommandList className="w-36 mb-1.5 rounded-lg">
<CommandListGroup label={t('messageInput.effort.label' as TranslationKey)}>
<div className="py-0.5">
{levels.map((level) => (
<CommandListItem
key={level}
active={selectedEffort === level}
onClick={() => {
onEffortChange(level);
setEffortMenuOpen(false);
}}
className="justify-between"
>
<span className="text-xs">{t(`messageInput.effort.${level}` as TranslationKey)}</span>
{selectedEffort === level && <span className="text-xs">&#10003;</span>}
</CommandListItem>
))}
</div>
</CommandListGroup>
</CommandList>
)}
</div>
);
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { File } from "@phosphor-icons/react";
import { File } from "@/components/ui/icon";
import { formatFileSize } from '@/types';
interface FileCardProps {
+5 -4
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Folder, FolderOpen, ArrowRight, CaretUp } from "@phosphor-icons/react";
import { Folder, FolderOpen, ArrowRight, CaretUp } from "@/components/ui/icon";
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -172,15 +172,16 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
) : (
<div className="p-1">
{directories.map((dir) => (
<button
<Button
key={dir.path}
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
variant="ghost"
className="flex w-full items-center gap-2 justify-start px-3 py-1.5 text-sm text-left h-auto"
onClick={() => handleNavigate(dir.path)}
>
<Folder size={16} className="shrink-0 text-primary" />
<span className="truncate">{dir.name}</span>
<ArrowRight size={12} className="ml-auto shrink-0 text-muted-foreground" />
</button>
</Button>
))}
</div>
)}
+5 -5
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useCallback } from 'react';
import { DownloadSimple, ArrowsCounterClockwise, PaintBrush } from '@phosphor-icons/react';
import { DownloadSimple, ArrowsCounterClockwise, PaintBrush } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -91,11 +91,11 @@ export function ImageGenCard({
{/* Image grid */}
<div className={cn('grid gap-2', gridCols)}>
{images.map((img, i) => (
<button
<Button
key={i}
type="button"
variant="ghost"
onClick={() => handlePreview(i)}
className="relative group overflow-hidden rounded-md border border-border/30 bg-muted/30"
className="relative group overflow-hidden rounded-md border border-border/30 bg-muted/30 p-0 h-auto"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -104,7 +104,7 @@ export function ImageGenCard({
className="w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</button>
</Button>
))}
</div>
+15 -17
View File
@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ImageGenCard } from './ImageGenCard';
import { useTranslation } from '@/hooks/useTranslation';
import { usePanel } from '@/hooks/usePanel';
@@ -71,7 +72,7 @@ export function ImageGenConfirmation({
} catch {
// ignore
}
}, [initialPrompt]);
}, [initialPrompt, sessionId]);
const handleStop = useCallback(() => {
if (abortRef.current) {
@@ -193,7 +194,7 @@ export function ImageGenConfirmation({
} finally {
abortRef.current = null;
}
}, [prompt, aspectRatio, resolution, initialPrompt, sessionId, referenceImages]);
}, [prompt, aspectRatio, resolution, initialPrompt, sessionId, messageId, referenceImages]);
const handleRegenerate = useCallback(() => {
setResult(null);
@@ -203,7 +204,7 @@ export function ImageGenConfirmation({
} catch {
// ignore
}
}, [initialPrompt]);
}, [initialPrompt, sessionId]);
// ── Completed: show result only ──
if (status === 'completed' && result && result.images.length > 0) {
@@ -258,14 +259,13 @@ export function ImageGenConfirmation({
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('imageGen.prompt' as TranslationKey)}
</label>
<textarea
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={status === 'generating'}
rows={3}
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'resize-none focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30',
'resize-none',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
/>
@@ -278,21 +278,20 @@ export function ImageGenConfirmation({
</label>
<div className="flex flex-wrap gap-1.5">
{ASPECT_RATIOS.map((ratio) => (
<button
<Button
key={ratio}
type="button"
variant="outline"
size="xs"
disabled={status === 'generating'}
onClick={() => setAspectRatio(ratio)}
className={cn(
'px-2.5 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
aspectRatio === ratio
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
{ratio}
</button>
</Button>
))}
</div>
</div>
@@ -304,21 +303,20 @@ export function ImageGenConfirmation({
</label>
<div className="flex items-center gap-1.5">
{RESOLUTIONS.map((res) => (
<button
<Button
key={res}
type="button"
variant="outline"
size="xs"
disabled={status === 'generating'}
onClick={() => setResolution(res)}
className={cn(
'px-3 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
resolution === res
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
{res}
</button>
</Button>
))}
</div>
</div>
@@ -357,7 +355,7 @@ export function ImageGenConfirmation({
{/* Error */}
{status === 'error' && error && (
<div className="space-y-2">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<p className="text-sm text-status-error-foreground">{error}</p>
<Button onClick={handleGenerate} variant="outline" size="sm">
{t('imageGen.retryButton' as TranslationKey)}
</Button>
+6 -4
View File
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
import { useImageGen } from '@/hooks/useImageGen';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
@@ -21,18 +22,19 @@ export function ImageGenToggle() {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={handleToggle}
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 h-7 text-xs font-medium border transition-all',
'rounded-full px-2.5 h-7 text-xs font-medium border transition-all',
state.enabled
? 'bg-primary/15 text-primary border-primary/30'
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
)}
>
{t('composer.designAgent' as TranslationKey)}
</button>
</Button>
</TooltipTrigger>
<TooltipContent>
{t('composer.designAgentTooltip' as TranslationKey)}
+10 -7
View File
@@ -1,7 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react";
import { ArrowLeft, ArrowRight } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -60,20 +61,22 @@ export function ImageLightbox({ images, initialIndex, open, onOpenChange }: Imag
{images.length > 1 && (
<>
<button
type="button"
<Button
variant="ghost"
size="icon"
onClick={goToPrev}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition"
>
<ArrowLeft size={24} />
</button>
<button
type="button"
</Button>
<Button
variant="ghost"
size="icon"
onClick={goToNext}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition"
>
<ArrowRight size={24} />
</button>
</Button>
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-white/70 text-sm">
{currentIndex + 1} / {images.length}
</div>
+6 -4
View File
@@ -1,5 +1,7 @@
'use client';
import { Button } from '@/components/ui/button';
interface ImageThumbnailProps {
src: string;
alt: string;
@@ -8,10 +10,10 @@ interface ImageThumbnailProps {
export function ImageThumbnail({ src, alt, onClick }: ImageThumbnailProps) {
return (
<button
type="button"
<Button
variant="ghost"
onClick={onClick}
className="rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition"
className="rounded-lg overflow-hidden cursor-pointer hover:opacity-80 p-0 h-auto"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -19,6 +21,6 @@ export function ImageThumbnail({ src, alt, onClick }: ImageThumbnailProps) {
alt={alt}
className="max-h-32 w-full object-cover rounded-lg"
/>
</button>
</Button>
);
}
File diff suppressed because it is too large. Load diff
+199
View File
@@ -0,0 +1,199 @@
'use client';
import { useEffect } from 'react';
import { ArrowUp, Plus, X, Stop, Terminal } from '@/components/ui/icon';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
import {
PromptInputButton,
PromptInputSubmit,
usePromptInputAttachments,
} from '@/components/ai-elements/prompt-input';
import type { ChatStatus } from 'ai';
import { isSubmitEnabled } from '@/lib/message-input-logic';
/**
* Submit button that's aware of file attachments. Must be rendered inside PromptInput.
*/
export function FileAwareSubmitButton({
status,
onStop,
disabled,
inputValue,
hasBadge,
}: {
status: ChatStatus;
onStop?: () => void;
disabled?: boolean;
inputValue: string;
hasBadge: boolean;
}) {
const attachments = usePromptInputAttachments();
const hasFiles = attachments.files.length > 0;
const isStreaming = status === 'streaming' || status === 'submitted';
const enabled = isSubmitEnabled({
inputValue,
hasBadge,
hasFiles,
isStreaming,
disabled: !!disabled,
});
return (
<PromptInputSubmit
status={status}
onStop={onStop}
disabled={!enabled}
className="rounded-full"
>
{isStreaming ? (
<Stop size={16} />
) : (
<ArrowUp size={16} />
)}
</PromptInputSubmit>
);
}
/**
* Attachment button that opens the file dialog. Must be rendered inside PromptInput.
*/
export function AttachFileButton() {
const attachments = usePromptInputAttachments();
const { t } = useTranslation();
return (
<PromptInputButton
onClick={() => attachments.openFileDialog()}
tooltip={t('messageInput.attachFiles')}
>
<Plus size={16} />
</PromptInputButton>
);
}
/**
* Bridge component that listens for 'attach-file-to-chat' custom events
* from the file tree and inserts `@filepath` into the textarea.
*/
export function FileTreeAttachmentBridge() {
useEffect(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ path: string }>;
const filePath = customEvent.detail?.path;
if (!filePath) return;
window.dispatchEvent(new CustomEvent('insert-file-mention', { detail: { path: filePath } }));
};
window.addEventListener('attach-file-to-chat', handler);
return () => window.removeEventListener('attach-file-to-chat', handler);
}, []);
return null;
}
/**
* Capsule display for attached files, rendered inside PromptInput context.
*/
export function FileAttachmentsCapsules() {
const attachments = usePromptInputAttachments();
if (attachments.files.length === 0) return null;
return (
<div className="flex w-full flex-wrap items-center gap-1.5 px-3 pt-2 pb-0 order-first">
{attachments.files.map((file) => {
const isImage = file.mediaType?.startsWith('image/');
return (
<span
key={file.id}
className="inline-flex items-center gap-1.5 rounded-full bg-status-success-muted text-status-success-foreground pl-2 pr-1 py-0.5 text-xs font-medium border border-status-success-border"
>
{isImage && file.url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={file.url}
alt={file.filename || 'image'}
className="h-5 w-5 rounded object-cover"
/>
)}
<span className="max-w-[120px] truncate text-[11px]">
{file.filename || 'file'}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => attachments.remove(file.id)}
className="ml-0.5 h-auto w-auto rounded-full p-0.5 hover:bg-status-success-border"
>
<X size={12} />
</Button>
</span>
);
})}
</div>
);
}
/**
* Slash-command badge displayed above the textarea.
*/
export function CommandBadge({
command,
description,
onRemove,
}: {
command: string;
description?: string;
onRemove: () => void;
}) {
return (
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 text-primary pl-2.5 pr-1.5 py-1 text-xs font-medium border border-primary/20">
<span className="font-mono">{command}</span>
{description && (
<span className="text-primary/60 text-[10px]">{description}</span>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="ml-0.5 h-auto w-auto rounded-full p-0.5 hover:bg-primary/20"
>
<X size={12} />
</Button>
</span>
</div>
);
}
/**
* CLI tool badge displayed above the textarea.
*/
export function CliBadge({
name,
onRemove,
}: {
name: string;
onRemove: () => void;
}) {
return (
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
<span className="inline-flex items-center gap-1.5 rounded-full bg-status-success-muted text-status-success-foreground pl-2.5 pr-1.5 py-1 text-xs font-medium border border-status-success-border">
<Terminal size={12} />
<span>CLI: {name}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="ml-0.5 h-auto w-auto rounded-full p-0.5 hover:bg-status-success-border"
>
<X size={12} />
</Button>
</span>
</div>
);
}
+14 -11
View File
@@ -8,7 +8,8 @@ import {
MessageResponse,
} from '@/components/ai-elements/message';
import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group';
import { Copy, Check, CaretDown, CaretUp } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { Copy, Check, CaretDown, CaretUp } from "@/components/ui/icon";
import { FileAttachmentDisplay } from './FileAttachmentDisplay';
import { ImageGenConfirmation } from './ImageGenConfirmation';
import { ImageGenCard } from './ImageGenCard';
@@ -280,18 +281,19 @@ function CopyButton({ text }: { text: string }) {
}, [text]);
return (
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs text-muted-foreground/60 hover:text-muted-foreground h-auto"
title="Copy"
>
{copied ? (
<Check size={12} className="text-green-500" />
<Check size={12} className="text-status-success-foreground" />
) : (
<Copy size={12} />
)}
</button>
</Button>
);
}
@@ -405,10 +407,11 @@ export const MessageItem = memo(function MessageItem({ message }: MessageItemPro
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-secondary to-transparent pointer-events-none" />
)}
{isOverflowing && (
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="relative z-10 flex items-center gap-1 mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
className="relative z-10 flex items-center gap-1 mt-1 text-xs text-muted-foreground hover:text-foreground h-auto px-1 py-0.5"
>
{isExpanded ? (
<>
@@ -421,7 +424,7 @@ export const MessageItem = memo(function MessageItem({ message }: MessageItemPro
<span>展开</span>
</>
)}
</button>
</Button>
)}
</div>
) : <AssistantContent displayText={displayText} messageId={message.id} />
@@ -477,7 +480,7 @@ const AssistantContent = memo(function AssistantContent({ displayText, messageId
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3">
<p className="text-sm text-red-600 dark:text-red-400">{result.error || 'Image generation failed'}</p>
<p className="text-sm text-status-error-foreground">{result.error || 'Image generation failed'}</p>
</div>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
+22 -13
View File
@@ -4,6 +4,7 @@ import { useRef, useEffect, useState, useCallback } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { useStickToBottomContext } from 'use-stick-to-bottom';
import { Button } from '@/components/ui/button';
import type { Message } from '@/types';
import {
Conversation,
@@ -92,7 +93,7 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes
if (state === 'done') {
return (
<span className="text-[10px] text-green-600 dark:text-green-400 ml-2">
<span className="text-[10px] text-status-success-foreground ml-2">
{t('messageList.rewindDone' as TranslationKey)}
</span>
);
@@ -104,30 +105,36 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes
<span className="text-[10px] text-muted-foreground">
{preview.filesChanged?.length || 0} files, +{preview.insertions || 0}/-{preview.deletions || 0}
</span>
<button
<Button
variant="link"
size="xs"
onClick={handleRewind}
className="text-[10px] text-primary hover:underline"
className="text-[10px] text-primary h-auto p-0"
>
{t('messageList.rewindConfirm' as TranslationKey)}
</button>
<button
</Button>
<Button
variant="link"
size="xs"
onClick={() => setState('idle')}
className="text-[10px] text-muted-foreground hover:underline"
className="text-[10px] text-muted-foreground h-auto p-0"
>
{t('messageList.rewindCancel' as TranslationKey)}
</button>
</Button>
</span>
);
}
return (
<button
<Button
variant="ghost"
size="xs"
onClick={handleDryRun}
disabled={state === 'loading'}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors ml-2 opacity-0 group-hover:opacity-100"
className="text-[10px] text-muted-foreground hover:text-foreground ml-2 opacity-0 group-hover:opacity-100 h-auto p-0"
>
{state === 'loading' ? '...' : t('messageList.rewindToHere' as TranslationKey)}
</button>
</Button>
);
}
@@ -223,13 +230,15 @@ export function MessageList({
<ConversationContent className="mx-auto max-w-3xl px-4 py-6 gap-6">
{hasMore && (
<div className="flex justify-center">
<button
<Button
variant="ghost"
size="sm"
onClick={handleLoadMore}
disabled={loadingMore}
className="text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
className="text-muted-foreground hover:text-foreground"
>
{loadingMore ? t('messageList.loading') : t('messageList.loadEarlier')}
</button>
</Button>
</div>
)}
{messages.map((message) => {
+40 -45
View File
@@ -1,12 +1,21 @@
'use client';
import { useRef, useState, useEffect, useCallback } from 'react';
import { CaretDown, Gear } from '@phosphor-icons/react';
import { CaretDown, Gear } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { PromptInputButton } from '@/components/ai-elements/prompt-input';
import type { ProviderModelGroup } from '@/types';
import {
CommandList,
CommandListSearch,
CommandListItems,
CommandListItem,
CommandListGroup,
CommandListFooter,
CommandListFooterAction,
} from '@/components/patterns';
interface ModelOption {
value: string;
@@ -79,71 +88,57 @@ export function ModelSelectorDropdown({
</PromptInputButton>
{modelMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-64 rounded-xl border bg-popover shadow-lg overflow-hidden z-50">
{/* Search */}
<div className="px-3 py-2 border-b">
<input
type="text"
placeholder={t('composer.searchModels' as TranslationKey)}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setModelMenuOpen(false);
setModelSearch('');
}
}}
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
</div>
{/* Model list */}
<div className="max-h-64 overflow-y-auto py-1">
<CommandList className="w-64 mb-1.5">
<CommandListSearch
placeholder={t('composer.searchModels' as TranslationKey)}
value={modelSearch}
onChange={setModelSearch}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setModelMenuOpen(false);
setModelSearch('');
}
}}
/>
<CommandListItems>
{filteredGroups.map((group, groupIdx) => (
<div key={group.provider_id}>
<div className={cn(
"px-3 py-1.5 text-[10px] font-medium text-muted-foreground",
groupIdx > 0 && "border-t"
)}>
{group.provider_name}
</div>
<CommandListGroup
key={group.provider_id}
label={group.provider_name}
separator={groupIdx > 0}
>
<div className="py-0.5">
{group.models.map((opt) => {
const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue;
return (
<button
<CommandListItem
key={`${group.provider_id}-${opt.value}`}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
active={isActive}
onClick={() => handleModelSelect(group.provider_id, opt.value)}
className="justify-between"
>
<span className="font-mono text-xs">{opt.label}</span>
{isActive && <span className="text-xs">&#10003;</span>}
</button>
</CommandListItem>
);
})}
</div>
</div>
</CommandListGroup>
))}
{filteredGroups.length === 0 && (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
No models found
</div>
)}
</div>
{/* Footer: manage providers */}
<div className="border-t px-3 py-1.5">
<button
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
onClick={() => { setModelMenuOpen(false); setModelSearch(''); window.location.href = '/settings'; }}
>
</CommandListItems>
<CommandListFooter>
<CommandListFooterAction onClick={() => { setModelMenuOpen(false); setModelSearch(''); window.location.href = '/settings'; }}>
<Gear size={14} />
{t('composer.manageProviders' as TranslationKey)}
</button>
</div>
</div>
</CommandListFooterAction>
</CommandListFooter>
</CommandList>
)}
</div>
);
+49 -38
View File
@@ -21,6 +21,8 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { ToolUIPart } from 'ai';
import type { PermissionRequestEvent } from '@/types';
@@ -112,54 +114,56 @@ function AskUserQuestionUI({
{q.options.map((opt) => {
const isSelected = selected.has(opt.label);
return (
<button
<Button
key={opt.label}
variant="outline"
size="sm"
onClick={() => toggleOption(qIdx, opt.label, q.multiSelect)}
className={`rounded-lg border h-8 px-3 text-sm font-medium transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}`}
className={isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}
title={opt.description}
>
{q.multiSelect && (
<span className="mr-1.5">{isSelected ? '☑' : '☐'}</span>
)}
{opt.label}
</button>
</Button>
);
})}
<button
<Button
variant="outline"
size="sm"
onClick={() => toggleOther(qIdx, q.multiSelect)}
className={`rounded-lg border h-8 px-3 text-sm font-medium transition-colors ${
useOther[qIdx]
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}`}
className={useOther[qIdx]
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}
>
Other
</button>
</Button>
</div>
{useOther[qIdx] && (
<input
<Input
type="text"
placeholder="Type your answer..."
value={otherTexts[qIdx] || ''}
onChange={(e) => setOtherTexts((prev) => ({ ...prev, [qIdx]: e.target.value }))}
className="w-full rounded-md border border-border bg-background px-3 py-1.5 text-xs focus:border-primary focus:outline-none"
className="text-xs"
autoFocus
/>
)}
</div>
);
})}
<button
<Button
onClick={handleSubmit}
disabled={!hasAnswer}
className="rounded-lg bg-primary h-8 px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
size="sm"
>
Submit
</button>
</Button>
</div>
);
}
@@ -221,14 +225,18 @@ function ExitPlanModeUI({
</div>
)}
<div className="flex gap-2">
<button
<Button
variant="outline"
size="sm"
onClick={onDeny}
className="rounded-lg border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
className="text-xs"
>
Reject
</button>
</Button>
{planFilePath && (
<button
<Button
variant="outline"
size="sm"
onClick={async () => {
setPlanLoading(true);
try {
@@ -246,20 +254,21 @@ function ExitPlanModeUI({
setPlanOpen(true);
}}
disabled={planLoading}
className="rounded-lg border border-primary/30 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
className="border-primary/30 text-xs text-primary hover:bg-primary/10"
>
{planLoading ? 'Loading...' : 'View Plan'}
</button>
</Button>
)}
<button
<Button
size="sm"
onClick={onApprove}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
className="text-xs"
>
Approve & Execute
</button>
</Button>
</div>
<div className="flex gap-2">
<input
<Input
type="text"
placeholder="Provide feedback on the plan..."
value={feedback}
@@ -269,17 +278,19 @@ function ExitPlanModeUI({
onDenyWithMessage(feedback.trim());
}
}}
className="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-xs focus:border-primary focus:outline-none"
className="flex-1 text-xs"
/>
<button
<Button
variant="outline"
size="sm"
onClick={() => {
if (feedback.trim()) onDenyWithMessage(feedback.trim());
}}
disabled={!feedback.trim()}
className="rounded-lg border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted disabled:opacity-40"
className="text-xs"
>
Do this instead
</button>
</Button>
</div>
{planOpen && planContent && (
@@ -365,10 +376,10 @@ export function PermissionPrompt({
/>
)}
{pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Plan approved — executing</p>
<p className="py-1 text-xs text-status-success-foreground">Plan approved — executing</p>
)}
{pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'deny' && (
<p className="py-1 text-xs text-red-600 dark:text-red-400">Plan rejected</p>
<p className="py-1 text-xs text-status-error-foreground">Plan rejected</p>
)}
{/* AskUserQuestion */}
@@ -379,7 +390,7 @@ export function PermissionPrompt({
/>
)}
{pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Answer submitted</p>
<p className="py-1 text-xs text-status-success-foreground">Answer submitted</p>
)}
{/* Generic confirmation for other tools */}
@@ -429,11 +440,11 @@ export function PermissionPrompt({
</ConfirmationRequest>
<ConfirmationAccepted>
<p className="text-xs text-green-600 dark:text-green-400">{t('streaming.allowed')}</p>
<p className="text-xs text-status-success-foreground">{t('streaming.allowed')}</p>
</ConfirmationAccepted>
<ConfirmationRejected>
<p className="text-xs text-red-600 dark:text-red-400">{t('streaming.denied')}</p>
<p className="text-xs text-status-error-foreground">{t('streaming.denied')}</p>
</ConfirmationRejected>
</Confirmation>
)}
+1 -1
View File
@@ -2,7 +2,7 @@
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { Lightning } from '@phosphor-icons/react';
import { Lightning } from '@/components/ui/icon';
import {
Tooltip,
TooltipContent,
+83 -118
View File
@@ -1,34 +1,21 @@
'use client';
import { useCallback } from 'react';
import {
At,
Terminal,
NotePencil,
Brain,
GlobeSimple,
Lightning,
} from '@phosphor-icons/react';
import type { Icon } from '@phosphor-icons/react';
import { cn } from '@/lib/utils';
import { At, Terminal, NotePencil, Brain, GlobeSimple, Lightning } from '@/components/ui/icon';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { SkillKind } from '@/types';
import type { PopoverItem, PopoverMode } from '@/types';
import {
CommandList,
CommandListSearch,
CommandListItems,
CommandListItem,
CommandListGroup,
CommandListFooter,
CommandListFooterAction,
} from '@/components/patterns';
export interface PopoverItem {
label: string;
value: string;
description?: string;
descriptionKey?: TranslationKey;
builtIn?: boolean;
immediate?: boolean;
installedSource?: 'agents' | 'claude';
source?: 'global' | 'project' | 'plugin' | 'installed' | 'sdk';
kind?: SkillKind;
icon?: Icon;
}
export type PopoverMode = 'file' | 'skill' | 'cli' | null;
export type { PopoverItem, PopoverMode } from '@/types';
interface SlashCommandPopoverProps {
popoverMode: PopoverMode;
@@ -95,8 +82,7 @@ export function SlashCommandPopover({
}
}, [selectedIndex, allDisplayedItems, onSetSelectedIndex, onInsertItem, onClosePopover, onFocusTextarea]);
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
const handleFilterChange = useCallback((val: string) => {
onSetPopoverFilter(val);
onSetSelectedIndex(0);
// Sync textarea: replace the filter portion after /
@@ -107,13 +93,10 @@ export function SlashCommandPopover({
}, [triggerPos, inputValue, onSetPopoverFilter, onSetSelectedIndex, onSetInputValue]);
const renderItem = (item: PopoverItem, idx: number) => (
<button
<CommandListItem
key={`${idx}-${item.value}`}
ref={idx === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
idx === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
active={idx === selectedIndex}
itemRef={idx === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
onClick={() => onInsertItem(item)}
onMouseEnter={() => onSetSelectedIndex(idx)}
>
@@ -141,103 +124,85 @@ export function SlashCommandPopover({
{item.installedSource === 'claude' ? 'Personal' : 'Agents'}
</span>
)}
</button>
</CommandListItem>
);
if (!popoverMode || popoverMode === 'cli') return null;
if (allDisplayedItems.length === 0 && !aiSearchLoading) return null;
return (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-full max-w-2xl rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
>
{popoverMode === 'skill' ? (
<div className="px-3 py-2 border-b">
<input
ref={searchInputRef}
type="text"
placeholder="Search..."
<div ref={popoverRef}>
<CommandList className="w-full max-w-2xl">
{popoverMode === 'skill' ? (
<CommandListSearch
inputRef={searchInputRef}
value={popoverFilter}
onChange={handleFilterChange}
onKeyDown={handleSearchKeyDown}
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
</div>
) : (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b">
Files
</div>
)}
<div className="max-h-48 overflow-y-auto py-1">
{popoverMode === 'file' ? (
filteredItems.map((item, i) => renderItem(item, i))
) : (
<>
{builtInItems.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Commands
</div>
{builtInItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</>
)}
{slashCommandItems.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Slash Commands
</div>
{slashCommandItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</>
)}
{agentSkillItems.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Agent Skills
</div>
{agentSkillItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</>
)}
{/* AI Suggested section */}
{(aiSuggestions.length > 0 || aiSearchLoading) && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Brain size={14} />
{t('messageInput.aiSuggested')}
{aiSearchLoading && (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
</div>
{aiSuggestions.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</>
)}
</>
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b">
Files
</div>
)}
</div>
{/* Footer: manage skills (skill mode only) */}
{popoverMode === 'skill' && (
<div className="border-t px-3 py-1.5">
<button
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
onClick={() => { onClosePopover(); window.location.href = '/skills'; }}
>
<Lightning size={14} />
{t('composer.manageSkills' as TranslationKey)}
</button>
</div>
)}
<CommandListItems className="max-h-48">
{popoverMode === 'file' ? (
filteredItems.map((item, i) => renderItem(item, i))
) : (
<>
{builtInItems.length > 0 && (
<CommandListGroup label="Commands">
{builtInItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</CommandListGroup>
)}
{slashCommandItems.length > 0 && (
<CommandListGroup label="Slash Commands">
{slashCommandItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</CommandListGroup>
)}
{agentSkillItems.length > 0 && (
<CommandListGroup label="Agent Skills">
{agentSkillItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</CommandListGroup>
)}
{/* AI Suggested section */}
{(aiSuggestions.length > 0 || aiSearchLoading) && (
<CommandListGroup>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Brain size={14} />
{t('messageInput.aiSuggested')}
{aiSearchLoading && (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
</div>
{aiSuggestions.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</CommandListGroup>
)}
</>
)}
</CommandListItems>
{/* Footer: manage skills (skill mode only) */}
{popoverMode === 'skill' && (
<CommandListFooter>
<CommandListFooterAction onClick={() => { onClosePopover(); window.location.href = '/skills'; }}>
<Lightning size={14} />
{t('composer.manageSkills' as TranslationKey)}
</CommandListFooterAction>
</CommandListFooter>
)}
</CommandList>
</div>
);
}
+9 -7
View File
@@ -8,6 +8,7 @@ import {
MessageResponse,
} from '@/components/ai-elements/message';
import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group';
import { Button } from '@/components/ui/button';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { ImageGenConfirmation } from './ImageGenConfirmation';
import { BatchPlanInlinePreview } from './batch-image-gen/BatchPlanInlinePreview';
@@ -140,26 +141,27 @@ function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string;
return (
<div className="flex items-center gap-3 py-2 px-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className={isCritical ? 'text-red-500' : isWarning ? 'text-yellow-500' : undefined}>
<span className={isCritical ? 'text-status-error-foreground' : isWarning ? 'text-status-warning-foreground' : undefined}>
<Shimmer duration={1.5}>{displayText}</Shimmer>
</span>
{isWarning && !isCritical && (
<span className="text-yellow-500 text-[10px]">Running longer than usual</span>
<span className="text-status-warning-foreground text-[10px]">Running longer than usual</span>
)}
{isCritical && (
<span className="text-red-500 text-[10px]">Tool may be stuck</span>
<span className="text-status-error-foreground text-[10px]">Tool may be stuck</span>
)}
</div>
<span className="text-muted-foreground/50">|</span>
<ElapsedTimer />
{isCritical && onForceStop && (
<button
type="button"
<Button
variant="outline"
size="xs"
onClick={onForceStop}
className="ml-auto rounded-md border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium text-red-500 transition-colors hover:bg-red-500/20"
className="ml-auto border-status-error-border bg-status-error-muted text-[10px] font-medium text-status-error-foreground hover:bg-status-error-muted"
>
Force stop
</button>
</Button>
)}
</div>
);
+16 -26
View File
@@ -1,20 +1,9 @@
'use client';
import { useState, createElement } from 'react';
import type { Icon } from "@phosphor-icons/react";
import {
File,
NotePencil,
Terminal,
MagnifyingGlass,
Wrench,
CaretDown,
CaretRight,
SpinnerGap,
CheckCircle,
XCircle,
} from "@phosphor-icons/react";
import { type Icon, File, NotePencil, Terminal, MagnifyingGlass, Wrench, CaretDown, CaretRight, SpinnerGap, CheckCircle, XCircle } from "@/components/ui/icon";
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { CodeBlock } from '@/components/ai-elements/code-block';
type ToolStatus = 'running' | 'success' | 'error';
@@ -104,9 +93,9 @@ function StatusIndicator({ status }: { status: ToolStatus }) {
</span>
);
case 'success':
return <CheckCircle size={14} className="text-green-500" />;
return <CheckCircle size={14} className="text-status-success-foreground" />;
case 'error':
return <XCircle size={14} className="text-red-500" />;
return <XCircle size={14} className="text-status-error-foreground" />;
}
}
@@ -127,13 +116,13 @@ function renderDiff(input: unknown): React.ReactNode | null {
<div className="my-2 rounded-md border border-zinc-700/50 overflow-hidden text-xs font-mono">
{oldLines.length > 0 && oldLines.map((line, i) => (
<div key={`old-${i}`} className="flex bg-red-950/30 text-red-300">
<span className="select-none w-8 text-right pr-2 text-red-400/60 shrink-0">-</span>
<span className="select-none w-8 text-right pr-2 text-red-400/60 shrink-0">-</span>{/* lint-allow-raw-color */}
<span className="px-2 whitespace-pre-wrap break-all">{line}</span>
</div>
))}
{newLines.length > 0 && newLines.map((line, i) => (
<div key={`new-${i}`} className="flex bg-green-950/30 text-green-300">
<span className="select-none w-8 text-right pr-2 text-green-400/60 shrink-0">+</span>
<span className="select-none w-8 text-right pr-2 text-green-400/60 shrink-0">+</span>{/* lint-allow-raw-color */}
<span className="px-2 whitespace-pre-wrap break-all">{line}</span>
</div>
))}
@@ -212,7 +201,7 @@ export function ToolCallBlock({
<div className="space-y-2">
{command && (
<div className="rounded-md bg-black p-3 font-mono text-xs text-zinc-100 overflow-x-auto">
<span className="text-green-400 select-none">$ </span>
<span className="text-green-400 select-none">$ </span>{/* lint-allow-raw-color */}
<span className="whitespace-pre-wrap break-all">{command}</span>
</div>
)}
@@ -282,22 +271,23 @@ export function ToolCallBlock({
const statusBorderColor = {
running: 'border-primary/70',
success: 'border-green-500/50',
error: 'border-red-500/60',
success: 'border-status-success-border',
error: 'border-status-error-border',
}[status];
const statusBgColor = {
running: 'bg-primary/[0.03] dark:bg-primary/[0.05]',
success: 'bg-transparent',
error: 'bg-red-500/[0.03] dark:bg-red-500/[0.05]',
error: 'bg-status-error-muted',
}[status];
return (
<div className={cn("my-0.5 border-l-2 rounded-r-md overflow-hidden transition-colors duration-300", statusBorderColor, statusBgColor)}>
<button
<Button
variant="ghost"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1 text-left text-sm hover:bg-muted/30 transition-colors",
"flex w-full items-center gap-2 px-3 py-1 text-left text-sm hover:bg-muted/30 h-auto rounded-none justify-start",
expanded && "border-b border-border/30"
)}
>
@@ -309,8 +299,8 @@ export function ToolCallBlock({
{createElement(toolIcon, { size: 14, className: cn(
"shrink-0",
category === 'read' && "text-primary",
category === 'write' && "text-amber-500",
category === 'bash' && "text-green-500",
category === 'write' && "text-status-warning-foreground",
category === 'bash' && "text-status-success-foreground",
category === 'search' && "text-primary",
category === 'other' && "text-muted-foreground",
) })}
@@ -321,7 +311,7 @@ export function ToolCallBlock({
)}
<StatusIndicator status={status} />
</div>
</button>
</Button>
<div className={cn(
"grid transition-[grid-template-rows] duration-200 ease-in-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useBatchImageGen } from '@/hooks/useBatchImageGen';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { Button } from '@/components/ui/button';
export function BatchContextSync() {
const { state, syncToLlm, resetJob } = useBatchImageGen();
@@ -37,41 +38,43 @@ export function BatchContextSync() {
<div className="px-4 py-3 flex items-center gap-2">
{!synced ? (
<>
<button
type="button"
<Button
size="sm"
onClick={() => handleSync('manual')}
disabled={isSyncing}
className="text-xs px-3 py-1.5 rounded-md bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 transition-colors"
className="text-xs bg-purple-600 text-white hover:bg-purple-700"
>
{isSyncing ? '...' : t('batchImageGen.syncToChat' as TranslationKey)}
</button>
</Button>
<div className="flex-1" />
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={resetJob}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t('common.close' as TranslationKey)}
</button>
</Button>
</>
) : (
<>
<span className="text-xs text-green-500">
<span className="text-xs text-status-success-foreground">
{t('batchImageGen.syncComplete' as TranslationKey)}
</span>
<div className="flex-1" />
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={resetJob}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t('common.close' as TranslationKey)}
</button>
</Button>
</>
)}
{state.error && (
<p className="text-xs text-red-500">{state.error}</p>
<p className="text-xs text-status-error-foreground">{state.error}</p>
)}
</div>
</div>
@@ -3,6 +3,7 @@
import { useBatchImageGen } from '@/hooks/useBatchImageGen';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { Button } from '@/components/ui/button';
import { BatchExecutionItem } from './BatchExecutionItem';
export function BatchExecutionDashboard() {
@@ -32,7 +33,7 @@ export function BatchExecutionDashboard() {
<span className="text-xs text-muted-foreground">
{progress.completed}/{progress.total}
{progress.failed > 0 && (
<span className="text-red-500 ml-1">({progress.failed} failed)</span>
<span className="text-status-error-foreground ml-1">({progress.failed} failed)</span>
)}
</span>
</div>
@@ -62,54 +63,58 @@ export function BatchExecutionDashboard() {
<div className="px-4 py-3 border-t border-border/40 flex items-center gap-2">
{isRunning && (
<>
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={pauseJob}
className="text-xs px-3 py-1.5 rounded-md border border-border/40 hover:bg-accent/50 text-foreground transition-colors"
className="text-xs border-border/40 hover:bg-accent/50"
>
{t('batchImageGen.pause' as TranslationKey)}
</button>
<button
type="button"
</Button>
<Button
variant="outline"
size="sm"
onClick={cancelJob}
className="text-xs px-3 py-1.5 rounded-md border border-red-500/30 text-red-500 hover:bg-red-500/10 transition-colors"
className="text-xs border-status-error-border text-status-error-foreground hover:bg-status-error-muted"
>
{t('batchImageGen.cancel' as TranslationKey)}
</button>
</Button>
</>
)}
{isPaused && (
<>
<button
type="button"
<Button
size="sm"
onClick={resumeJob}
className="text-xs px-3 py-1.5 rounded-md bg-purple-600 text-white hover:bg-purple-700 transition-colors"
className="text-xs bg-purple-600 text-white hover:bg-purple-700"
>
{t('batchImageGen.resume' as TranslationKey)}
</button>
<button
type="button"
</Button>
<Button
variant="outline"
size="sm"
onClick={cancelJob}
className="text-xs px-3 py-1.5 rounded-md border border-red-500/30 text-red-500 hover:bg-red-500/10 transition-colors"
className="text-xs border-status-error-border text-status-error-foreground hover:bg-status-error-muted"
>
{t('batchImageGen.cancel' as TranslationKey)}
</button>
</Button>
</>
)}
{isCompleted && hasFailedItems && (
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={retryFailed}
className="text-xs px-3 py-1.5 rounded-md border border-border/40 hover:bg-accent/50 text-foreground transition-colors"
className="text-xs border-border/40 hover:bg-accent/50"
>
{t('batchImageGen.retryFailed' as TranslationKey)}
</button>
</Button>
)}
{state.error && (
<p className="text-xs text-red-500 ml-2">{state.error}</p>
<p className="text-xs text-status-error-foreground ml-2">{state.error}</p>
)}
</div>
</div>
@@ -14,8 +14,8 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
const statusColor = {
pending: 'text-muted-foreground',
processing: 'text-primary',
completed: 'text-green-500',
failed: 'text-red-500',
completed: 'text-status-success-foreground',
failed: 'text-status-error-foreground',
cancelled: 'text-muted-foreground',
}[item.status];
@@ -39,14 +39,14 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
{item.status === 'processing' ? (
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
) : item.status === 'completed' ? (
<div className="w-4 h-4 rounded-full bg-green-500/20 flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-green-500" viewBox="0 0 12 12" fill="none">
<div className="w-4 h-4 rounded-full bg-status-success-muted flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-status-success-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
) : item.status === 'failed' ? (
<div className="w-4 h-4 rounded-full bg-red-500/20 flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-red-500" viewBox="0 0 12 12" fill="none">
<div className="w-4 h-4 rounded-full bg-status-error-muted flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-status-error-foreground" viewBox="0 0 12 12" fill="none">
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
@@ -63,7 +63,7 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
<span className="text-[10px] text-muted-foreground">{item.image_size}</span>
<span className={`text-[10px] font-medium ${statusColor}`}>{statusLabel}</span>
{item.error && (
<span className="text-[10px] text-red-400 truncate">{item.error}</span>
<span className="text-[10px] text-status-error-foreground truncate">{item.error}</span>
)}
</div>
</div>
@@ -5,6 +5,7 @@ import { useBatchImageGen } from '@/hooks/useBatchImageGen';
import { usePanel } from '@/hooks/usePanel';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { Button } from '@/components/ui/button';
import type { PlannerOutput, PlannerItem } from '@/types';
import { BatchPlanRow } from './BatchPlanRow';
import { BatchExecutionDashboard } from './BatchExecutionDashboard';
@@ -15,7 +16,7 @@ interface BatchPlanInlinePreviewProps {
messageId: string;
}
export function BatchPlanInlinePreview({ plan: initialPlan, messageId }: BatchPlanInlinePreviewProps) {
export function BatchPlanInlinePreview({ plan: initialPlan }: BatchPlanInlinePreviewProps) {
const batchImageGen = useBatchImageGen();
const { sessionId } = usePanel();
const { t } = useTranslation();
@@ -105,22 +106,23 @@ export function BatchPlanInlinePreview({ plan: initialPlan, messageId }: BatchPl
{/* Footer Actions */}
<div className="px-4 py-3 border-t border-border/40 flex items-center gap-2">
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={handleAddItem}
className="text-xs text-purple-600 dark:text-purple-400 hover:underline"
className="text-xs text-purple-600 dark:text-purple-400 hover:underline h-auto px-1 py-0.5"
>
+ {t('batchImageGen.addItem' as TranslationKey)}
</button>
</Button>
<div className="flex-1" />
<button
type="button"
<Button
size="sm"
onClick={handleExecute}
disabled={localPlan.items.length === 0}
className="inline-flex items-center justify-center rounded-lg px-4 py-1.5 text-xs font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="bg-purple-600 text-white hover:bg-purple-700"
>
{t('batchImageGen.confirmAndExecute' as TranslationKey)}
</button>
</Button>
</div>
</div>
);
@@ -3,6 +3,8 @@
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { PlannerItem } from '@/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
const ASPECT_RATIOS = ['1:1', '16:9', '9:16', '3:2', '2:3', '4:3', '3:4', '4:5', '5:4', '21:9'];
const RESOLUTIONS = ['1K', '2K', '4K'];
@@ -26,10 +28,10 @@ export function BatchPlanRow({ item, index, onUpdate, onRemove, disabled }: Batc
</span>
<div className="flex-1 min-w-0">
{/* Prompt */}
<textarea
<Textarea
value={item.prompt}
onChange={e => onUpdate(index, { prompt: e.target.value })}
className="w-full rounded-md border border-border/40 bg-transparent px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-purple-500/50"
className="border-border/40 bg-transparent px-2 py-1.5 text-sm resize-none"
rows={2}
disabled={disabled}
placeholder={t('batchImageGen.prompt' as TranslationKey)}
@@ -37,11 +39,12 @@ export function BatchPlanRow({ item, index, onUpdate, onRemove, disabled }: Batc
{/* Controls Row */}
<div className="flex items-center gap-2 mt-1.5">
{/* Aspect Ratio */}
{/* Aspect Ratio — native select for compact inline use */}
{/* eslint-disable-next-line no-restricted-syntax */}
<select
value={item.aspectRatio}
onChange={e => onUpdate(index, { aspectRatio: e.target.value })}
className="rounded-md border border-border/40 bg-transparent px-1.5 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-purple-500/50"
className="rounded-md border border-border/40 bg-transparent px-1.5 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
disabled={disabled}
>
{ASPECT_RATIOS.map(r => (
@@ -49,11 +52,12 @@ export function BatchPlanRow({ item, index, onUpdate, onRemove, disabled }: Batc
))}
</select>
{/* Resolution */}
{/* Resolution — native select for compact inline use */}
{/* eslint-disable-next-line no-restricted-syntax */}
<select
value={item.resolution}
onChange={e => onUpdate(index, { resolution: e.target.value })}
className="rounded-md border border-border/40 bg-transparent px-1.5 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-purple-500/50"
className="rounded-md border border-border/40 bg-transparent px-1.5 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
disabled={disabled}
>
{RESOLUTIONS.map(r => (
@@ -75,14 +79,15 @@ export function BatchPlanRow({ item, index, onUpdate, onRemove, disabled }: Batc
<div className="flex-1" />
{/* Remove */}
<button
type="button"
<Button
variant="ghost"
size="xs"
onClick={() => onRemove(index)}
disabled={disabled}
className="text-[10px] text-muted-foreground hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
className="text-[10px] text-muted-foreground hover:text-status-error-foreground opacity-0 group-hover:opacity-100 disabled:opacity-0 h-auto p-0"
>
{t('batchImageGen.removeItem' as TranslationKey)}
</button>
</Button>
</div>
</div>
</div>
@@ -17,7 +17,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { SpinnerGap, CheckCircle, XCircle, Sparkle } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, XCircle, Sparkle } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
@@ -251,6 +251,7 @@ export function CliToolBatchDescribeDialog({
{/* Skip existing toggle */}
{existingCount > 0 && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
{/* eslint-disable-next-line no-restricted-syntax -- no Checkbox UI component available */}
<input
type="checkbox"
checked={skipExisting}
@@ -276,10 +277,10 @@ export function CliToolBatchDescribeDialog({
<span className="w-4 h-4 rounded-full border border-muted-foreground/30 shrink-0" />
)}
{r.status === 'loading' && (
<SpinnerGap size={16} className="animate-spin text-blue-500 shrink-0" />
<SpinnerGap size={16} className="animate-spin text-status-info-foreground shrink-0" />
)}
{r.status === 'success' && (
<CheckCircle size={16} weight="fill" className="text-green-500 shrink-0" />
<CheckCircle size={16} weight="fill" className="text-status-success-foreground shrink-0" />
)}
{r.status === 'error' && (
<XCircle size={16} weight="fill" className="text-destructive shrink-0" />
@@ -297,7 +298,7 @@ export function CliToolBatchDescribeDialog({
<div className="flex flex-col gap-2 py-2">
<div className="flex items-center gap-4 text-sm">
{successCount > 0 && (
<span className="flex items-center gap-1 text-green-600">
<span className="flex items-center gap-1 text-status-success-foreground">
<CheckCircle size={16} weight="fill" />
{t('cliTools.batchSuccess', { count: String(successCount) })}
</span>
+6 -4
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Plus, CaretDown } from "@phosphor-icons/react";
import { Plus, CaretDown } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
@@ -102,13 +102,15 @@ export function CliToolCard({
{showMethodPicker && availableMethods.length > 1 && (
<div className="absolute right-0 top-8 z-10 rounded-md border bg-popover p-1 shadow-md min-w-[140px]">
{availableMethods.map(m => (
<button
<Button
key={m.method}
className="w-full text-left px-2 py-1 text-xs rounded hover:bg-muted transition-colors"
variant="ghost"
size="sm"
className="w-full justify-start px-2 py-1 text-xs h-auto"
onClick={(e) => handleMethodSelect(e, m.method)}
>
{m.method}: {m.command}
</button>
</Button>
))}
</div>
)}
@@ -9,7 +9,7 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Copy, ArrowSquareOut, Plus, CaretDown } from "@phosphor-icons/react";
import { Copy, ArrowSquareOut, Plus, CaretDown } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import type { CliToolDefinition, CliToolPlatform } from "@/types";
@@ -172,16 +172,18 @@ export function CliToolDetailDialog({
{showMethodPicker && availableMethods.length > 1 && (
<div className="absolute right-0 bottom-10 z-10 rounded-md border bg-popover p-1 shadow-md min-w-[180px]">
{availableMethods.map(m => (
<button
<Button
key={m.method}
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-muted transition-colors"
variant="ghost"
size="sm"
className="w-full justify-start px-2 py-1.5 text-xs h-auto"
onClick={() => {
setShowMethodPicker(false);
onInstall(tool, m.method);
}}
>
{m.method}: {m.command}
</button>
</Button>
))}
</div>
)}
@@ -9,7 +9,7 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { SpinnerGap, CheckCircle, X } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, X } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import type { CliToolDefinition } from "@/types";
@@ -133,10 +133,10 @@ export function CliToolInstallDialog({
<SpinnerGap size={20} className="animate-spin text-primary" />
)}
{phase === "success" && (
<CheckCircle size={20} className="text-green-500" />
<CheckCircle size={20} className="text-status-success-foreground" />
)}
{phase === "error" && (
<X size={20} className="text-red-500" />
<X size={20} className="text-status-error-foreground" />
)}
{phase === "running"
? `${t('cliTools.installing')} ${tool.name}...`
+3 -3
View File
@@ -9,7 +9,7 @@ import { CliToolDetailDialog } from "./CliToolDetailDialog";
import { CliToolExtraDetailDialog } from "./CliToolExtraDetailDialog";
import { CliToolInstallDialog } from "./CliToolInstallDialog";
import { CliToolBatchDescribeDialog } from "./CliToolBatchDescribeDialog";
import { SpinnerGap, Sparkle, ArrowSquareOut, Warning } from "@phosphor-icons/react";
import { SpinnerGap, Sparkle, ArrowSquareOut, Warning } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { EXTRA_WELL_KNOWN_BINS } from "@/lib/cli-tools-catalog";
@@ -175,8 +175,8 @@ export function CliToolsManager() {
{/* Brew not installed warning */}
{!hasBrew && (platform === 'darwin' || platform === 'linux') && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 mb-3">
<Warning size={16} className="text-amber-500 shrink-0 mt-0.5" />
<div className="flex items-start gap-2 rounded-lg border border-status-warning-border bg-status-warning-muted px-3 py-2.5 mb-3">
<Warning size={16} className="text-status-warning-foreground shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">{t('cliTools.brewNotInstalled')}</p>
<p>{t('cliTools.brewInstallGuide')}</p>
+18 -15
View File
@@ -10,7 +10,7 @@ import {
ArrowLeft,
ArrowRight,
Heart,
} from '@phosphor-icons/react';
} from '@/components/ui/icon';
import { cn, parseDBDate } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -140,20 +140,22 @@ export function GalleryDetail({
{hasMultipleImages && (
<>
<button
type="button"
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentImageIndex((i) => (i > 0 ? i - 1 : item.images.length - 1))}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition z-10"
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70 z-10"
>
<ArrowLeft size={20} />
</button>
<button
type="button"
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentImageIndex((i) => (i < item.images.length - 1 ? i + 1 : 0))}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition z-10"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70 z-10"
>
<ArrowRight size={20} />
</button>
</Button>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-xs text-white z-10">
{currentImageIndex + 1} / {item.images.length}
</div>
@@ -164,14 +166,15 @@ export function GalleryDetail({
{/* Right: Info panel */}
<div className="flex-1 min-w-0 border-l border-border/50 overflow-y-auto p-6 space-y-5">
{/* Favorite button */}
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={() => onToggleFavorite?.(item.id)}
className={cn(
'flex items-center gap-1.5 text-sm transition-colors',
'gap-1.5',
item.favorited
? 'text-red-500'
: 'text-muted-foreground hover:text-red-500',
? 'text-status-error-foreground'
: 'text-muted-foreground hover:text-status-error-foreground',
)}
>
<Heart
@@ -181,7 +184,7 @@ export function GalleryDetail({
{item.favorited
? t('gallery.removeFromFavorites' as TranslationKey)
: t('gallery.addToFavorites' as TranslationKey)}
</button>
</Button>
{/* Prompt */}
<div>
+2 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { PaintBrush, Heart } from '@phosphor-icons/react';
import { PaintBrush, Heart } from '@/components/ui/icon';
export interface GalleryItem {
id: string;
@@ -73,7 +73,7 @@ export function GalleryGrid({ items, onSelect }: GalleryGridProps) {
)}
{item.favorited && (
<span className="absolute top-1.5 left-1.5">
<Heart size={16} className="text-red-500 drop-shadow" weight="fill" />
<Heart size={16} className="text-status-error-foreground drop-shadow" weight="fill" />
</span>
)}
</div>
+18 -14
View File
@@ -1,10 +1,11 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { Plus, X } from '@phosphor-icons/react';
import { Plus, X } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
@@ -62,11 +63,12 @@ export function TagManager({
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
return (
<button
<Button
key={tag.id}
type="button"
variant="ghost"
size="xs"
onClick={() => onToggleTag?.(tag.id)}
className="group inline-flex items-center gap-1"
className="group inline-flex items-center gap-1 h-auto p-0"
>
<Badge
variant={isSelected ? 'default' : 'outline'}
@@ -85,19 +87,20 @@ export function TagManager({
)}
{tag.name}
{editable && onRemoveTag && (
<button
type="button"
<Button
variant="ghost"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
onRemoveTag(tag.id);
}}
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 hover:bg-foreground/10 transition-opacity"
className="ml-0.5 rounded-full h-4 w-4 opacity-0 group-hover:opacity-100 hover:bg-foreground/10"
>
<X size={10} />
</button>
</Button>
)}
</Badge>
</button>
</Button>
);
})}
@@ -115,7 +118,7 @@ export function TagManager({
{adding && (
<div className="flex items-center gap-2">
<input
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
@@ -124,17 +127,18 @@ export function TagManager({
if (e.key === 'Escape') setAdding(false);
}}
placeholder={t('gallery.newTagPlaceholder' as TranslationKey)}
className="flex-1 h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
className="flex-1 h-7 text-xs"
autoFocus
/>
<div className="flex items-center gap-1">
{PRESET_COLORS.map((color) => (
<button
<Button
key={color}
type="button"
variant="ghost"
size="icon-xs"
onClick={() => setNewColor(color)}
className={cn(
'h-4 w-4 rounded-full transition-all',
'h-4 w-4 rounded-full p-0',
newColor === color ? 'ring-2 ring-offset-1 ring-offset-background' : 'hover:scale-110'
)}
style={{ backgroundColor: color, '--tw-ring-color': color } as React.CSSProperties}
+40 -194
View File
@@ -11,7 +11,8 @@ import { UpdateDialog } from "./UpdateDialog";
import { UpdateBanner } from "./UpdateBanner";
import { DocPreview } from "./DocPreview";
import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel";
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
import { UpdateContext } from "@/hooks/useUpdate";
import { useUpdateChecker } from "@/hooks/useUpdateChecker";
import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen";
import { BatchImageGenContext, useBatchImageGenState } from "@/hooks/useBatchImageGen";
import { SplitContext, type SplitSession } from "@/hooks/useSplit";
@@ -65,14 +66,15 @@ function defaultViewMode(filePath: string): PreviewViewMode {
}
const LG_BREAKPOINT = 1024;
const CHECK_INTERVAL = 8 * 60 * 60 * 1000; // 8 hours
const DISMISSED_VERSION_KEY = "codepilot_dismissed_update_version";
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [chatListOpen, setChatListOpenRaw] = useState(false);
const [chatListOpenRaw, setChatListOpenRaw] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`).matches;
});
// Panel width state with localStorage persistence
const [chatListWidth, setChatListWidth] = useState(() => {
@@ -104,20 +106,14 @@ export function AppShell({ children }: { children: React.ReactNode }) {
});
}, []);
// Panel state
// Panel state — chatListOpen is derived: raw state gated by route
const isChatRoute = pathname.startsWith("/chat/") || pathname === "/chat";
const chatListOpen = chatListOpenRaw && isChatRoute;
// Auto-close chat list when leaving chat routes
const setChatListOpen = useCallback((open: boolean) => {
setChatListOpenRaw(open);
}, []);
useEffect(() => {
if (!isChatRoute) {
setChatListOpenRaw(false);
}
}, [isChatRoute]);
const [panelOpen, setPanelOpenRaw] = useState(false);
const [panelOpenRaw, setPanelOpenRaw] = useState(false);
const [panelContent, setPanelContent] = useState<PanelContent>("files");
const [workingDirectory, setWorkingDirectory] = useState("");
const [sessionId, setSessionId] = useState("");
@@ -263,13 +259,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return () => window.removeEventListener("session-deleted", handler);
}, []);
// Exit split when navigating to non-chat routes
// Exit split when navigating to non-chat routes — setState is intentional here
// (derived-from-route-change pattern, not subscribing to external system)
useEffect(() => {
if (isSplitActive && !pathname.startsWith("/chat")) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSplitSessions([]);
setActiveColumnIdRaw("");
}
}, [isSplitActive, pathname]);
}, [pathname, isSplitActive]);
const splitContextValue = useMemo(
() => ({
@@ -321,209 +319,57 @@ export function AppShell({ children }: { children: React.ReactNode }) {
});
}, []);
// Auto-open panel on chat detail routes, close on others
// Also close doc preview when navigating away or switching sessions
// Sync panel state on route changes: open on chat detail, close otherwise.
// Reset doc preview when navigating between pages/sessions.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPanelOpenRaw(isChatDetailRoute);
setPreviewFileRaw(null);
}, [isChatDetailRoute, pathname]);
}, [pathname, isChatDetailRoute]);
const panelOpen = panelOpenRaw;
const setPanelOpen = useCallback((open: boolean) => {
setPanelOpenRaw(open);
}, []);
// Keep chat list state in sync when resizing across the breakpoint (only on chat routes)
// Keep chat list state in sync when resizing across the breakpoint
useEffect(() => {
if (!isChatRoute) return;
const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
const handler = (e: MediaQueryListEvent) => setChatListOpenRaw(e.matches);
mql.addEventListener("change", handler);
setChatListOpenRaw(mql.matches);
return () => mql.removeEventListener("change", handler);
}, [isChatRoute]);
}, []);
// --- Skip-permissions indicator ---
const [skipPermissionsActive, setSkipPermissionsActive] = useState(false);
const fetchSkipPermissions = useCallback(async () => {
try {
const res = await fetch("/api/settings/app");
if (res.ok) {
const data = await res.json();
setSkipPermissionsActive(data.settings?.dangerously_skip_permissions === "true");
}
} catch {
// ignore
}
}, []);
// Re-fetch when window gains focus / becomes visible instead of polling every 5s
// Fetch on mount + re-fetch when window gains focus / becomes visible
useEffect(() => {
fetchSkipPermissions();
let cancelled = false;
const doFetch = async () => {
try {
const res = await fetch("/api/settings/app");
if (res.ok && !cancelled) {
const data = await res.json();
setSkipPermissionsActive(data.settings?.dangerously_skip_permissions === "true");
}
} catch { /* ignore */ }
};
doFetch();
const handleVisibility = () => {
if (document.visibilityState === "visible") {
fetchSkipPermissions();
}
if (document.visibilityState === "visible") doFetch();
};
document.addEventListener("visibilitychange", handleVisibility);
window.addEventListener("focus", fetchSkipPermissions);
window.addEventListener("focus", doFetch);
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", handleVisibility);
window.removeEventListener("focus", fetchSkipPermissions);
window.removeEventListener("focus", doFetch);
};
}, [fetchSkipPermissions]);
// --- Update check state ---
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [checking, setChecking] = useState(false);
const [showDialog, setShowDialog] = useState(false);
// Runtime detection: native updater available when running in Electron with updater bridge
const isNativeUpdater = typeof window !== "undefined" && !!window.electronAPI?.updater;
// --- Native updater status listener ---
useEffect(() => {
if (!isNativeUpdater) return;
const cleanup = window.electronAPI!.updater!.onStatus((event) => {
switch (event.status) {
case 'available':
setUpdateInfo((prev) => ({
updateAvailable: true,
latestVersion: event.info?.version ?? prev?.latestVersion ?? '',
currentVersion: prev?.currentVersion ?? '',
releaseName: event.info?.releaseName ?? prev?.releaseName ?? '',
releaseNotes: typeof event.info?.releaseNotes === 'string' ? event.info.releaseNotes : prev?.releaseNotes ?? '',
releaseUrl: prev?.releaseUrl ?? '',
publishedAt: event.info?.releaseDate ?? prev?.publishedAt ?? '',
downloadProgress: null,
readyToInstall: false,
isNativeUpdate: true,
lastError: null,
}));
{
const ver = event.info?.version;
const dismissed = localStorage.getItem(DISMISSED_VERSION_KEY);
if (ver && dismissed !== ver) {
setShowDialog(true);
}
}
break;
case 'not-available':
setUpdateInfo((prev) => prev ? { ...prev, updateAvailable: false, isNativeUpdate: true, lastError: null } : prev);
break;
case 'downloading':
setUpdateInfo((prev) => prev ? {
...prev,
downloadProgress: event.progress?.percent ?? prev.downloadProgress,
isNativeUpdate: true,
lastError: null,
} : prev);
break;
case 'downloaded':
setUpdateInfo((prev) => prev ? {
...prev,
readyToInstall: true,
downloadProgress: 100,
isNativeUpdate: true,
lastError: null,
} : prev);
break;
case 'error':
setUpdateInfo((prev) => prev ? {
...prev,
lastError: event.error ?? 'Unknown error',
isNativeUpdate: true,
} : prev);
break;
}
if (event.status === 'checking') {
setChecking(true);
} else {
setChecking(false);
}
});
return cleanup;
}, [isNativeUpdater]);
// --- Browser-mode update check (fallback for non-Electron) ---
const checkForUpdatesBrowser = useCallback(async () => {
setChecking(true);
try {
const res = await fetch("/api/app/updates");
if (!res.ok) return;
const data = await res.json();
const info: UpdateInfo = {
...data,
downloadProgress: null,
readyToInstall: false,
isNativeUpdate: false,
lastError: null,
};
setUpdateInfo(info);
if (info.updateAvailable) {
const dismissed = localStorage.getItem(DISMISSED_VERSION_KEY);
if (dismissed !== info.latestVersion) {
setShowDialog(true);
}
}
} catch {
// silently ignore network errors
} finally {
setChecking(false);
}
}, []);
// --- Unified check: native first, browser fallback ---
const checkForUpdates = useCallback(async () => {
if (isNativeUpdater) {
try {
await window.electronAPI!.updater!.checkForUpdates();
return;
} catch {
// native check failed, fall through to browser mode
}
}
await checkForUpdatesBrowser();
}, [isNativeUpdater, checkForUpdatesBrowser]);
// Browser mode: periodic check (non-Electron or as fallback)
useEffect(() => {
if (isNativeUpdater) return; // native updater handles its own initial check
checkForUpdatesBrowser();
const id = setInterval(checkForUpdatesBrowser, CHECK_INTERVAL);
return () => clearInterval(id);
}, [isNativeUpdater, checkForUpdatesBrowser]);
const dismissUpdate = useCallback(() => {
setShowDialog(false);
}, []);
const downloadUpdate = useCallback(async () => {
if (isNativeUpdater) {
await window.electronAPI!.updater!.downloadUpdate();
}
}, [isNativeUpdater]);
const quitAndInstall = useCallback(() => {
if (isNativeUpdater) {
window.electronAPI!.updater!.quitAndInstall();
}
}, [isNativeUpdater]);
const updateContextValue = useMemo(
() => ({
updateInfo,
checking,
checkForUpdates,
downloadUpdate,
dismissUpdate,
showDialog,
setShowDialog,
quitAndInstall,
}),
[updateInfo, checking, checkForUpdates, downloadUpdate, dismissUpdate, showDialog, quitAndInstall]
);
// --- Update checker (native Electron + browser fallback) ---
const updateContextValue = useUpdateChecker();
const panelContextValue = useMemo(
() => ({
@@ -565,8 +411,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<NavRail
chatListOpen={chatListOpen}
onToggleChatList={() => setChatListOpen(!chatListOpen)}
hasUpdate={updateInfo?.updateAvailable ?? false}
readyToInstall={updateInfo?.readyToInstall ?? false}
hasUpdate={updateContextValue.updateInfo?.updateAvailable ?? false}
readyToInstall={updateContextValue.updateInfo?.readyToInstall ?? false}
skipPermissionsActive={skipPermissionsActive}
/>
<ErrorBoundary>
+54 -316
View File
@@ -1,22 +1,13 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import {
Trash,
MagnifyingGlass,
Bell,
FileArrowDown,
Folder,
CaretDown,
CaretRight,
Plus,
FolderOpen,
UserCircle,
Columns,
X,
} from "@phosphor-icons/react";
} from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -25,15 +16,23 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn, parseDBDate } from "@/lib/utils";
import { usePanel } from "@/hooks/usePanel";
import { useSplit } from "@/hooks/useSplit";
import { useTranslation } from "@/hooks/useTranslation";
import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker";
import { ConnectionStatus } from "./ConnectionStatus";
import { ImportSessionDialog } from "./ImportSessionDialog";
import { SessionListItem, SplitGroupSection } from "./SessionListItem";
import { ProjectGroupHeader } from "./ProjectGroupHeader";
import { FolderPicker } from "@/components/chat/FolderPicker";
import { useAssistantWorkspace } from "@/hooks/useAssistantWorkspace";
import {
formatRelativeTime,
groupSessionsByProject,
loadCollapsedProjects,
saveCollapsedProjects,
COLLAPSED_INITIALIZED_KEY,
} from "./chat-list-utils";
import type { ChatSession } from "@/types";
interface ChatListPanelProps {
@@ -41,83 +40,11 @@ interface ChatListPanelProps {
width?: number;
}
function formatRelativeTime(dateStr: string, t: (key: import('@/i18n').TranslationKey, params?: Record<string, string | number>) => string): string {
const date = parseDBDate(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return t('chatList.justNow');
if (diffMin < 60) return t('chatList.minutesAgo', { n: diffMin });
if (diffHr < 24) return t('chatList.hoursAgo', { n: diffHr });
if (diffDay < 7) return t('chatList.daysAgo', { n: diffDay });
return date.toLocaleDateString();
}
const COLLAPSED_PROJECTS_KEY = "codepilot:collapsed-projects";
const COLLAPSED_INITIALIZED_KEY = "codepilot:collapsed-initialized";
function loadCollapsedProjects(): Set<string> {
if (typeof window === 'undefined') return new Set();
try {
const raw = localStorage.getItem(COLLAPSED_PROJECTS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch {
// ignore
}
return new Set();
}
function saveCollapsedProjects(collapsed: Set<string>) {
localStorage.setItem(COLLAPSED_PROJECTS_KEY, JSON.stringify([...collapsed]));
}
interface ProjectGroup {
workingDirectory: string;
displayName: string;
sessions: ChatSession[];
latestUpdatedAt: number;
}
function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] {
const map = new Map<string, ChatSession[]>();
for (const session of sessions) {
const key = session.working_directory || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(session);
}
const groups: ProjectGroup[] = [];
for (const [wd, groupSessions] of map) {
// Sort sessions within group by updated_at DESC
groupSessions.sort(
(a, b) =>
parseDBDate(b.updated_at).getTime() - parseDBDate(a.updated_at).getTime()
);
const displayName =
wd === ""
? "No Project"
: groupSessions[0]?.project_name || wd.split("/").pop() || wd;
const latestUpdatedAt = parseDBDate(groupSessions[0].updated_at).getTime();
groups.push({
workingDirectory: wd,
displayName,
sessions: groupSessions,
latestUpdatedAt,
});
}
// Sort groups by most recently active first
groups.sort((a, b) => b.latestUpdatedAt - a.latestUpdatedAt);
return groups;
}
export function ChatListPanel({ open, width }: ChatListPanelProps) {
const pathname = usePathname();
const router = useRouter();
const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory, sessionId: currentSessionId } = usePanel();
const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory } = usePanel();
const { splitSessions, isSplitActive, activeColumnId, addToSplit, removeFromSplit, setActiveColumn, isInSplit } = useSplit();
const { t } = useTranslation();
const { isElectron, openNativePicker } = useNativeFolderPicker();
@@ -211,7 +138,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
} finally {
setCreatingChat(false);
}
}, [router, workingDirectory, openFolderPicker]);
}, [router, workingDirectory, openFolderPicker, getCurrentModelAndProvider]);
const toggleProject = useCallback((wd: string) => {
setCollapsedProjects((prev) => {
@@ -452,66 +379,17 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
{/* Split group section */}
{isSplitActive && (
<div className="mb-2 rounded-lg border border-border/60 bg-muted/30 p-1.5">
<div className="flex items-center gap-1.5 px-2 py-1">
<Columns className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{t('split.splitGroup')}</span>
</div>
<div className="mt-0.5 flex flex-col gap-0.5">
{splitSessions.map((session) => {
const isActiveInSplit = activeColumnId === session.sessionId;
const isSessionStreaming =
activeStreamingSessions.has(session.sessionId) || streamingSessionId === session.sessionId;
const needsApproval =
pendingApprovalSessionIds.has(session.sessionId) || pendingApprovalSessionId === session.sessionId;
return (
<div
key={session.sessionId}
className={cn(
"group relative flex items-center gap-1.5 rounded-md pl-7 pr-2 py-1.5 transition-all duration-150 min-w-0 cursor-pointer",
isActiveInSplit
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/50"
)}
onClick={(e) => {
e.preventDefault();
setActiveColumn(session.sessionId);
}}
>
{isSessionStreaming && (
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
)}
{needsApproval && (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<Bell size={10} className="text-amber-500" />
</span>
)}
<div className="flex-1 min-w-0">
<span className="line-clamp-1 text-[13px] font-medium leading-tight break-all">
{session.title}
</span>
</div>
<Button
variant="ghost"
size="icon-xs"
className="h-4 w-4 shrink-0 text-muted-foreground/60 hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeFromSplit(session.sessionId);
}}
>
<X className="h-2.5 w-2.5" />
<span className="sr-only">{t('split.closeSplit')}</span>
</Button>
</div>
);
})}
</div>
</div>
<SplitGroupSection
splitSessions={splitSessions}
activeColumnId={activeColumnId}
streamingSessionId={streamingSessionId}
pendingApprovalSessionId={pendingApprovalSessionId}
activeStreamingSessions={activeStreamingSessions}
pendingApprovalSessionIds={pendingApprovalSessionIds}
t={t}
setActiveColumn={setActiveColumn}
removeFromSplit={removeFromSplit}
/>
)}
{filteredSessions.length === 0 && (!isSplitActive || splitSessions.length === 0) ? (
@@ -528,188 +406,48 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
return (
<div key={group.workingDirectory || "__no_project"} className="mt-1 first:mt-0">
{/* Folder header */}
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 cursor-pointer select-none transition-colors",
"hover:bg-accent/50"
)}
onClick={() => toggleProject(group.workingDirectory)}
onDoubleClick={(e) => {
e.stopPropagation();
if (group.workingDirectory) {
if (window.electronAPI?.shell?.openPath) {
window.electronAPI.shell.openPath(group.workingDirectory);
} else {
fetch('/api/files/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: group.workingDirectory }),
}).catch(() => {});
}
}
}}
onMouseEnter={() =>
setHoveredFolder(group.workingDirectory)
}
onMouseLeave={() => setHoveredFolder(null)}
>
{isCollapsed ? (
<CaretRight size={14} className="shrink-0 text-muted-foreground" />
) : (
<CaretDown size={14} className="shrink-0 text-muted-foreground" />
)}
{isCollapsed ? (
<Folder size={16} className="shrink-0 text-muted-foreground" />
) : (
<FolderOpen size={16} className="shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate text-[13px] font-medium text-sidebar-foreground">
{group.displayName}
</span>
{workspacePath && group.workingDirectory === workspacePath && (
<UserCircle size={14} className="shrink-0 text-muted-foreground" />
)}
{/* New chat in project button (on hover) */}
{group.workingDirectory !== "" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground transition-opacity",
isFolderHovered ? "opacity-100" : "opacity-0"
)}
tabIndex={isFolderHovered ? 0 : -1}
onClick={(e) =>
handleCreateSessionInProject(
e,
group.workingDirectory
)
}
>
<Plus size={14} />
<span className="sr-only">
New chat in {group.displayName}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
New chat in {group.displayName}
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs break-all">{group.workingDirectory || 'No Project'}</p>
{group.workingDirectory && <p className="text-[10px] text-muted-foreground mt-0.5">Double-click to open in Finder</p>}
</TooltipContent>
</Tooltip>
<ProjectGroupHeader
workingDirectory={group.workingDirectory}
displayName={group.displayName}
isCollapsed={isCollapsed}
isFolderHovered={isFolderHovered}
isWorkspace={!!(workspacePath && group.workingDirectory === workspacePath)}
onToggle={() => toggleProject(group.workingDirectory)}
onMouseEnter={() => setHoveredFolder(group.workingDirectory)}
onMouseLeave={() => setHoveredFolder(null)}
onCreateSession={(e) => handleCreateSessionInProject(e, group.workingDirectory)}
/>
{/* Session items */}
{!isCollapsed && (
<div className="mt-0.5 flex flex-col gap-0.5">
{group.sessions.map((session) => {
const isActive = pathname === `/chat/${session.id}`;
const isHovered = hoveredSession === session.id;
const isDeleting = deletingSession === session.id;
const isSessionStreaming =
activeStreamingSessions.has(session.id) || streamingSessionId === session.id;
const needsApproval =
pendingApprovalSessionIds.has(session.id) || pendingApprovalSessionId === session.id;
const canSplit = !isActive && !isInSplit(session.id);
return (
<div
<SessionListItem
key={session.id}
className="group relative"
onMouseEnter={() =>
setHoveredSession(session.id)
}
session={session}
isActive={isActive}
isHovered={hoveredSession === session.id}
isDeleting={deletingSession === session.id}
isSessionStreaming={activeStreamingSessions.has(session.id) || streamingSessionId === session.id}
needsApproval={pendingApprovalSessionIds.has(session.id) || pendingApprovalSessionId === session.id}
canSplit={canSplit}
formatRelativeTime={formatRelativeTime}
t={t}
onMouseEnter={() => setHoveredSession(session.id)}
onMouseLeave={() => setHoveredSession(null)}
>
<Link
href={`/chat/${session.id}`}
className={cn(
"flex items-center gap-1.5 rounded-md pl-2 pr-2 py-1.5 transition-all duration-150 min-w-0",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/50"
)}
>
{/* Left icon area — always same size, swap content via opacity */}
<span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{/* Split icon: visible on hover when splittable */}
{canSplit && (
<button
className={cn(
"absolute inset-0 flex items-center justify-center text-muted-foreground hover:text-foreground transition-opacity",
isHovered ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
addToSplit({
sessionId: session.id,
title: session.title,
workingDirectory: session.working_directory || "",
projectName: session.project_name || "",
mode: session.mode,
});
}}
>
<Columns className="h-3.5 w-3.5" />
</button>
)}
{/* Streaming indicator: hidden when hover shows split icon */}
{isSessionStreaming && (
<span className={cn(
"relative flex h-2 w-2 transition-opacity",
isHovered && canSplit ? "opacity-0" : "opacity-100"
)}>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
)}
{/* Approval indicator: hidden when hover shows split icon */}
{needsApproval && !isSessionStreaming && (
<span className={cn(
"flex h-3.5 w-3.5 items-center justify-center rounded-full bg-amber-500/10 transition-opacity",
isHovered && canSplit ? "opacity-0" : "opacity-100"
)}>
<Bell size={10} className="text-amber-500" />
</span>
)}
</span>
<div className="flex-1 min-w-0">
<span className="line-clamp-1 text-[13px] font-medium leading-tight break-all">
{session.title}
</span>
</div>
{/* Right area — fixed width, time and delete stacked with opacity */}
<div className="relative w-[38px] h-4 shrink-0">
<span className={cn(
"absolute inset-0 flex items-center justify-end text-[11px] text-muted-foreground/40 truncate transition-opacity",
(isHovered || isDeleting) ? "opacity-0" : "opacity-100"
)}>
{formatRelativeTime(session.updated_at, t)}
</span>
<button
className={cn(
"absolute inset-0 flex items-center justify-end text-muted-foreground/60 hover:text-destructive transition-opacity",
(isHovered || isDeleting) ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={(e) => handleDeleteSession(e, session.id)}
disabled={isDeleting}
>
<Trash size={14} />
</button>
</div>
</Link>
</div>
onDelete={handleDeleteSession}
onAddToSplit={(s) => addToSplit({
sessionId: s.id,
title: s.title,
workingDirectory: s.working_directory || "",
projectName: s.project_name || "",
mode: s.mode,
})}
/>
);
})}
</div>
+16 -13
View File
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
import { InstallWizard } from "@/components/layout/InstallWizard";
@@ -119,15 +120,17 @@ export function ConnectionStatus() {
return (
<>
<button
<Button
variant="ghost"
size="sm"
onClick={() => setDialogOpen(true)}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[11px] font-medium transition-colors",
"h-7 rounded-full px-2.5 text-[11px] font-medium gap-1.5",
status === null
? "bg-muted text-muted-foreground"
: connected
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
: "bg-red-500/15 text-red-700 dark:text-red-400"
? "bg-status-success-muted text-status-success-foreground"
: "bg-status-error-muted text-status-error-foreground"
)}
>
<span
@@ -136,8 +139,8 @@ export function ConnectionStatus() {
status === null
? "bg-muted-foreground/40"
: connected
? "bg-emerald-500"
: "bg-red-500"
? "bg-status-success"
: "bg-status-error"
)}
/>
{status === null
@@ -145,7 +148,7 @@ export function ConnectionStatus() {
: connected
? t('connection.connected')
: t('connection.disconnected')}
</button>
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
@@ -162,19 +165,19 @@ export function ConnectionStatus() {
{connected ? (
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-emerald-500" />
<div className="flex items-center gap-3 rounded-lg bg-status-success-muted px-4 py-3">
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-status-success" />
<div>
<p className="font-medium text-emerald-700 dark:text-emerald-400">Active</p>
<p className="font-medium text-status-success-foreground">Active</p>
<p className="text-xs text-muted-foreground">{t('connection.version', { version: status?.version ?? '' })}</p>
</div>
</div>
</div>
) : (
<div className="space-y-4 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-red-500/10 px-4 py-3">
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-red-500" />
<p className="font-medium text-red-700 dark:text-red-400">Not detected</p>
<div className="flex items-center gap-3 rounded-lg bg-status-error-muted px-4 py-3">
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-status-error" />
<p className="font-medium text-status-error-foreground">Not detected</p>
</div>
<div>
+13 -9
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react";
import { useTheme } from "next-themes";
import { X, Copy, Check, SpinnerGap } from "@phosphor-icons/react";
import { X, Copy, Check, SpinnerGap } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { useThemeFamily } from "@/lib/theme/context";
@@ -93,7 +93,7 @@ export function DocPreview({
return () => {
cancelled = true;
};
}, [filePath]);
}, [filePath, workingDirectory]);
const handleCopyContent = async () => {
const text = preview?.content || filePath;
@@ -131,7 +131,7 @@ export function DocPreview({
<Button variant="ghost" size="icon-sm" onClick={handleCopyContent}>
{copied ? (
<Check size={14} className="text-green-500" />
<Check size={14} className="text-status-success-foreground" />
) : (
<Copy size={14} />
)}
@@ -188,8 +188,10 @@ function ViewModeToggle({
}) {
return (
<div className="flex h-6 items-center rounded-full bg-muted p-0.5 text-[11px]">
<button
className={`rounded-full px-2 py-0.5 font-medium transition-colors ${
<Button
variant="ghost"
size="sm"
className={`rounded-full px-2 py-0.5 font-medium h-auto ${
value === "source"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
@@ -197,9 +199,11 @@ function ViewModeToggle({
onClick={() => onChange("source")}
>
Source
</button>
<button
className={`rounded-full px-2 py-0.5 font-medium transition-colors ${
</Button>
<Button
variant="ghost"
size="sm"
className={`rounded-full px-2 py-0.5 font-medium h-auto ${
value === "rendered"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
@@ -207,7 +211,7 @@ function ViewModeToggle({
onClick={() => onChange("rendered")}
>
Preview
</button>
</Button>
</div>
);
}
+12 -12
View File
@@ -1,7 +1,8 @@
"use client";
import React, { useState } from "react";
import { WarningCircle } from "@phosphor-icons/react";
import { WarningCircle } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/hooks/useTranslation";
interface ErrorBoundaryProps {
@@ -41,12 +42,14 @@ function ErrorFallback({
</p>
{error && (
<button
<Button
variant="link"
size="sm"
onClick={() => setShowDetails((s) => !s)}
className="text-xs text-muted-foreground underline hover:text-foreground"
className="text-xs text-muted-foreground hover:text-foreground"
>
{showDetails ? t("error.hideDetails") : t("error.showDetails")}
</button>
</Button>
)}
{showDetails && error && (
<pre className="max-h-40 w-full overflow-auto rounded-md border border-border/50 bg-muted/30 p-3 text-left text-xs text-muted-foreground">
@@ -56,18 +59,15 @@ function ErrorFallback({
)}
<div className="flex gap-2">
<button
onClick={onReset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Button onClick={onReset}>
{t("error.tryAgain")}
</button>
<button
</Button>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
{t("error.reloadApp")}
</button>
</Button>
</div>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import { useTheme } from "next-themes";
import { useCallback, useSyncExternalStore } from "react";
import { Moon, Sun } from "@phosphor-icons/react";
import { Moon, Sun } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -21,7 +21,7 @@ import {
Clock,
FileArrowDown,
ChatCircleText,
} from "@phosphor-icons/react";
} from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { cn, parseDBDate } from "@/lib/utils";
+18 -18
View File
@@ -19,7 +19,7 @@ import {
Circle,
Copy,
DownloadSimple,
} from "@phosphor-icons/react";
} from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
interface InstallProgress {
@@ -67,11 +67,11 @@ function getInstallAPI() {
function StepIcon({ status }: { status: string }) {
switch (status) {
case "success":
return <Check size={16} className="text-emerald-500" />;
return <Check size={16} className="text-status-success-foreground" />;
case "running":
return <SpinnerGap size={16} className="text-primary animate-spin" />;
case "failed":
return <X size={16} className="text-red-500" />;
return <X size={16} className="text-status-error-foreground" />;
case "skipped":
return <Minus size={16} className="text-muted-foreground" />;
default:
@@ -265,15 +265,15 @@ export function InstallWizard({
className={cn(
step.status === "pending" && "text-muted-foreground",
step.status === "running" && "text-foreground font-medium",
step.status === "success" && "text-emerald-700 dark:text-emerald-400",
step.status === "failed" && "text-red-700 dark:text-red-400",
step.status === "success" && "text-status-success-foreground",
step.status === "failed" && "text-status-error-foreground",
step.status === "skipped" && "text-muted-foreground"
)}
>
{step.label}
</span>
{step.error && (
<span className="text-xs text-red-500 ml-auto truncate max-w-[200px]">
<span className="text-xs text-status-error-foreground ml-auto truncate max-w-[200px]">
{step.error}
</span>
)}
@@ -293,8 +293,8 @@ export function InstallWizard({
{/* Phase: confirm — ask user before installing */}
{phase === "confirm" && prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin" && (
<div className="space-y-3">
<div className="rounded-lg bg-amber-500/10 px-4 py-3 text-sm space-y-1.5">
<p className="text-amber-700 dark:text-amber-400 font-medium">
<div className="rounded-lg bg-status-warning-muted px-4 py-3 text-sm space-y-1.5">
<p className="text-status-warning-foreground font-medium">
{t('install.homebrewRequired')}
</p>
<p className="text-muted-foreground text-xs">
@@ -334,18 +334,18 @@ export function InstallWizard({
)}
{phase === "confirm" && !(prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin") && (
<div className="space-y-3">
<div className="rounded-lg bg-amber-500/10 px-4 py-3 text-sm space-y-1.5">
<div className="rounded-lg bg-status-warning-muted px-4 py-3 text-sm space-y-1.5">
{prereqs && !prereqs.hasNode && (
<p className="text-amber-700 dark:text-amber-400">
<p className="text-status-warning-foreground">
Node.js — not found (will be installed via {prereqs.platform === "win32" ? "winget" : "Homebrew"})
</p>
)}
{prereqs?.hasNode && (
<p className="text-emerald-700 dark:text-emerald-400">
<p className="text-status-success-foreground">
Node.js {prereqs.nodeVersion} — found
</p>
)}
<p className="text-amber-700 dark:text-amber-400">
<p className="text-status-warning-foreground">
Claude Code CLI — not found
</p>
</div>
@@ -358,10 +358,10 @@ export function InstallWizard({
{/* Phase: already-installed */}
{phase === "already-installed" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<Check size={20} className="text-emerald-500 shrink-0" />
<div className="flex items-center gap-3 rounded-lg bg-status-success-muted px-4 py-3">
<Check size={20} className="text-status-success-foreground shrink-0" />
<div className="text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
<p className="font-medium text-status-success-foreground">
Already installed
</p>
<p className="text-muted-foreground text-xs">
@@ -373,10 +373,10 @@ export function InstallWizard({
{/* Phase: success */}
{phase === "success" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<Check size={20} className="text-emerald-500 shrink-0" />
<div className="flex items-center gap-3 rounded-lg bg-status-success-muted px-4 py-3">
<Check size={20} className="text-status-success-foreground shrink-0" />
<div className="text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
<p className="font-medium text-status-success-foreground">
{t('install.complete')}
</p>
<p className="text-muted-foreground text-xs">
+4 -4
View File
@@ -10,7 +10,7 @@ import {
Gear,
WifiHigh,
Terminal,
} from "@phosphor-icons/react";
} from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -120,8 +120,8 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
<TooltipTrigger asChild>
<div className="flex h-8 w-8 items-center justify-center">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-orange-500" />
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-warning opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-status-warning" />
</span>
</div>
</TooltipTrigger>
@@ -148,7 +148,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
{hasUpdate && (
<span className={cn(
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
readyToInstall ? "bg-green-500 animate-pulse" : "bg-primary"
readyToInstall ? "bg-status-success animate-pulse" : "bg-primary"
)} />
)}
</div>
@@ -0,0 +1,117 @@
"use client";
import {
Folder,
CaretDown,
CaretRight,
Plus,
FolderOpen,
UserCircle,
} from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface ProjectGroupHeaderProps {
workingDirectory: string;
displayName: string;
isCollapsed: boolean;
isFolderHovered: boolean;
isWorkspace: boolean;
onToggle: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
onCreateSession: (e: React.MouseEvent) => void;
}
export function ProjectGroupHeader({
workingDirectory,
displayName,
isCollapsed,
isFolderHovered,
isWorkspace,
onToggle,
onMouseEnter,
onMouseLeave,
onCreateSession,
}: ProjectGroupHeaderProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 cursor-pointer select-none transition-colors",
"hover:bg-accent/50"
)}
onClick={onToggle}
onDoubleClick={(e) => {
e.stopPropagation();
if (workingDirectory) {
if (window.electronAPI?.shell?.openPath) {
window.electronAPI.shell.openPath(workingDirectory);
} else {
fetch('/api/files/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: workingDirectory }),
}).catch(() => {});
}
}
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{isCollapsed ? (
<CaretRight size={14} className="shrink-0 text-muted-foreground" />
) : (
<CaretDown size={14} className="shrink-0 text-muted-foreground" />
)}
{isCollapsed ? (
<Folder size={16} className="shrink-0 text-muted-foreground" />
) : (
<FolderOpen size={16} className="shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate text-[13px] font-medium text-sidebar-foreground">
{displayName}
</span>
{isWorkspace && (
<UserCircle size={14} className="shrink-0 text-muted-foreground" />
)}
{/* New chat in project button (on hover) */}
{workingDirectory !== "" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground transition-opacity",
isFolderHovered ? "opacity-100" : "opacity-0"
)}
tabIndex={isFolderHovered ? 0 : -1}
onClick={onCreateSession}
>
<Plus size={14} />
<span className="sr-only">
New chat in {displayName}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
New chat in {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>}
</TooltipContent>
</Tooltip>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback } from "react";
import { TreeStructure, SidebarSimple } from "@phosphor-icons/react";
import { TreeStructure, SidebarSimple } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
Tooltip,
+217
View File
@@ -0,0 +1,217 @@
"use client";
import Link from "next/link";
import {
Trash,
Bell,
Columns,
X,
} from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ChatSession } from "@/types";
import type { TranslationKey } from "@/i18n";
interface SessionListItemProps {
session: ChatSession;
isActive: boolean;
isHovered: boolean;
isDeleting: boolean;
isSessionStreaming: boolean;
needsApproval: boolean;
canSplit: boolean;
formatRelativeTime: (dateStr: string, t: (key: TranslationKey, params?: Record<string, string | number>) => string) => string;
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
onMouseEnter: () => void;
onMouseLeave: () => void;
onDelete: (e: React.MouseEvent, sessionId: string) => void;
onAddToSplit: (session: ChatSession) => void;
}
export function SessionListItem({
session,
isActive,
isHovered,
isDeleting,
isSessionStreaming,
needsApproval,
canSplit,
formatRelativeTime,
t,
onMouseEnter,
onMouseLeave,
onDelete,
onAddToSplit,
}: SessionListItemProps) {
return (
<div
className="group relative"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Link
href={`/chat/${session.id}`}
className={cn(
"flex items-center gap-1.5 rounded-md pl-2 pr-2 py-1.5 transition-all duration-150 min-w-0",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/50"
)}
>
{/* Left icon area — always same size, swap content via opacity */}
<span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{/* Split icon: visible on hover when splittable */}
{canSplit && (
<Button
variant="ghost"
size="icon"
className={cn(
"absolute inset-0 flex items-center justify-center text-muted-foreground hover:text-foreground transition-opacity h-auto w-auto p-0",
isHovered ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAddToSplit(session);
}}
>
<Columns className="h-3.5 w-3.5" />
</Button>
)}
{/* Streaming indicator: hidden when hover shows split icon */}
{isSessionStreaming && (
<span className={cn(
"relative flex h-2 w-2 transition-opacity",
isHovered && canSplit ? "opacity-0" : "opacity-100"
)}>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-success opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-status-success" />
</span>
)}
{/* Approval indicator: hidden when hover shows split icon */}
{needsApproval && !isSessionStreaming && (
<span className={cn(
"flex h-3.5 w-3.5 items-center justify-center rounded-full bg-status-warning-muted transition-opacity",
isHovered && canSplit ? "opacity-0" : "opacity-100"
)}>
<Bell size={10} className="text-status-warning-foreground" />
</span>
)}
</span>
<div className="flex-1 min-w-0">
<span className="line-clamp-1 text-[13px] font-medium leading-tight break-all">
{session.title}
</span>
</div>
{/* Right area — fixed width, time and delete stacked with opacity */}
<div className="relative w-[38px] h-4 shrink-0">
<span className={cn(
"absolute inset-0 flex items-center justify-end text-[11px] text-muted-foreground/40 truncate transition-opacity",
(isHovered || isDeleting) ? "opacity-0" : "opacity-100"
)}>
{formatRelativeTime(session.updated_at, t)}
</span>
<Button
variant="ghost"
size="icon"
className={cn(
"absolute inset-0 flex items-center justify-end text-muted-foreground/60 hover:text-destructive transition-opacity h-auto w-auto p-0",
(isHovered || isDeleting) ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={(e) => onDelete(e, session.id)}
disabled={isDeleting}
>
<Trash size={14} />
</Button>
</div>
</Link>
</div>
);
}
interface SplitGroupSectionProps {
splitSessions: Array<{ sessionId: string; title: string }>;
activeColumnId: string;
streamingSessionId: string;
pendingApprovalSessionId: string;
activeStreamingSessions: Set<string>;
pendingApprovalSessionIds: Set<string>;
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
setActiveColumn: (sessionId: string) => void;
removeFromSplit: (sessionId: string) => void;
}
export function SplitGroupSection({
splitSessions,
activeColumnId,
streamingSessionId,
pendingApprovalSessionId,
activeStreamingSessions,
pendingApprovalSessionIds,
t,
setActiveColumn,
removeFromSplit,
}: SplitGroupSectionProps) {
return (
<div className="mb-2 rounded-lg border border-border/60 bg-muted/30 p-1.5">
<div className="flex items-center gap-1.5 px-2 py-1">
<Columns className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{t('split.splitGroup' as TranslationKey)}</span>
</div>
<div className="mt-0.5 flex flex-col gap-0.5">
{splitSessions.map((session) => {
const isActiveInSplit = activeColumnId === session.sessionId;
const isSessionStreaming =
activeStreamingSessions.has(session.sessionId) || streamingSessionId === session.sessionId;
const needsApproval =
pendingApprovalSessionIds.has(session.sessionId) || pendingApprovalSessionId === session.sessionId;
return (
<div
key={session.sessionId}
className={cn(
"group relative flex items-center gap-1.5 rounded-md pl-7 pr-2 py-1.5 transition-all duration-150 min-w-0 cursor-pointer",
isActiveInSplit
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/50"
)}
onClick={(e) => {
e.preventDefault();
setActiveColumn(session.sessionId);
}}
>
{isSessionStreaming && (
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-success opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-status-success" />
</span>
)}
{needsApproval && (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-status-warning-muted">
<Bell size={10} className="text-status-warning-foreground" />
</span>
)}
<div className="flex-1 min-w-0">
<span className="line-clamp-1 text-[13px] font-medium leading-tight break-all">
{session.title}
</span>
</div>
<Button
variant="ghost"
size="icon-xs"
className="h-4 w-4 shrink-0 text-muted-foreground/60 hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeFromSplit(session.sessionId);
}}
>
<X className="h-2.5 w-2.5" />
<span className="sr-only">{t('split.closeSplit' as TranslationKey)}</span>
</Button>
</div>
);
})}
</div>
</div>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { X } from "@phosphor-icons/react";
import { X } from "@/components/ui/icon";
import type { Message, MessagesResponse, ChatSession } from "@/types";
import { ChatView } from "@/components/chat/ChatView";
import { Button } from "@/components/ui/button";
+1 -1
View File
@@ -99,7 +99,7 @@ export function UpdateDialog() {
)}
{updateInfo.lastError && (
<p className="rounded-md border border-red-500/20 bg-red-500/10 px-2 py-1 text-xs text-red-600 dark:text-red-400">
<p className="rounded-md border border-status-error-border bg-status-error-muted px-2 py-1 text-xs text-status-error-foreground">
{updateInfo.lastError}
</p>
)}
+76
View File
@@ -0,0 +1,76 @@
import { parseDBDate } from "@/lib/utils";
import type { ChatSession } from "@/types";
import type { TranslationKey } from "@/i18n";
const COLLAPSED_PROJECTS_KEY = "codepilot:collapsed-projects";
export const COLLAPSED_INITIALIZED_KEY = "codepilot:collapsed-initialized";
export function loadCollapsedProjects(): Set<string> {
if (typeof window === 'undefined') return new Set();
try {
const raw = localStorage.getItem(COLLAPSED_PROJECTS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch {
// ignore
}
return new Set();
}
export function saveCollapsedProjects(collapsed: Set<string>) {
localStorage.setItem(COLLAPSED_PROJECTS_KEY, JSON.stringify([...collapsed]));
}
export interface ProjectGroup {
workingDirectory: string;
displayName: string;
sessions: ChatSession[];
latestUpdatedAt: number;
}
export function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] {
const map = new Map<string, ChatSession[]>();
for (const session of sessions) {
const key = session.working_directory || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(session);
}
const groups: ProjectGroup[] = [];
for (const [wd, groupSessions] of map) {
// Sort sessions within group by updated_at DESC
groupSessions.sort(
(a, b) =>
parseDBDate(b.updated_at).getTime() - parseDBDate(a.updated_at).getTime()
);
const displayName =
wd === ""
? "No Project"
: groupSessions[0]?.project_name || wd.split("/").pop() || wd;
const latestUpdatedAt = parseDBDate(groupSessions[0].updated_at).getTime();
groups.push({
workingDirectory: wd,
displayName,
sessions: groupSessions,
latestUpdatedAt,
});
}
// Sort groups by most recently active first
groups.sort((a, b) => b.latestUpdatedAt - a.latestUpdatedAt);
return groups;
}
export function formatRelativeTime(dateStr: string, t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
const date = parseDBDate(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return t('chatList.justNow');
if (diffMin < 60) return t('chatList.minutesAgo', { n: diffMin });
if (diffHr < 24) return t('chatList.hoursAgo', { n: diffHr });
if (diffDay < 7) return t('chatList.daysAgo', { n: diffDay });
return date.toLocaleDateString();
}
+184
View File
@@ -0,0 +1,184 @@
import { type ReactNode, type RefObject, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
/* ------------------------------------------------------------------ */
/* CommandList — shared popover/command-list pattern */
/* Pure presentation; no data fetching or business logic. */
/* ------------------------------------------------------------------ */
// ── Root container ──────────────────────────────────────────────────
interface CommandListProps {
children: ReactNode;
className?: string;
}
export function CommandList({ children, className }: CommandListProps) {
return (
<div
className={cn(
"absolute bottom-full left-0 mb-2 rounded-xl border bg-popover shadow-lg overflow-hidden z-50",
className,
)}
>
{children}
</div>
);
}
// ── Search input ────────────────────────────────────────────────────
interface CommandListSearchProps {
value: string;
onChange: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
placeholder?: string;
inputRef?: RefObject<HTMLInputElement | null>;
}
export function CommandListSearch({
value,
onChange,
onKeyDown,
placeholder = "Search...",
inputRef,
}: CommandListSearchProps) {
return (
<div className="px-3 py-2 border-b">
<Input
ref={inputRef}
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
className="h-auto w-full border-0 bg-transparent p-0 text-sm text-foreground shadow-none placeholder:text-muted-foreground outline-none focus-visible:ring-0 focus-visible:border-transparent"
/>
</div>
);
}
// ── Scrollable items area ───────────────────────────────────────────
interface CommandListItemsProps {
children: ReactNode;
className?: string;
}
export function CommandListItems({ children, className }: CommandListItemsProps) {
return (
<div className={cn("max-h-64 overflow-y-auto py-1", className)}>
{children}
</div>
);
}
// ── Single item ─────────────────────────────────────────────────────
interface CommandListItemProps {
active?: boolean;
onClick?: () => void;
onMouseEnter?: () => void;
children: ReactNode;
className?: string;
itemRef?: (el: HTMLButtonElement | null) => void;
}
export function CommandListItem({
active,
onClick,
onMouseEnter,
children,
className,
itemRef,
}: CommandListItemProps) {
return (
<Button
type="button"
ref={itemRef}
variant="ghost"
size="sm"
className={cn(
"flex w-full items-center justify-start gap-2 rounded-none px-3 py-1.5 text-left text-sm font-normal transition-colors h-auto",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
className,
)}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
{children}
</Button>
);
}
// ── Group with optional label and separator ─────────────────────────
interface CommandListGroupProps {
label?: string;
separator?: boolean;
children: ReactNode;
}
export function CommandListGroup({ label, separator, children }: CommandListGroupProps) {
return (
<div className={cn(separator && "border-t")}>
{label && (
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground">
{label}
</div>
)}
{children}
</div>
);
}
// ── Footer ──────────────────────────────────────────────────────────
interface CommandListFooterProps {
children: ReactNode;
}
export function CommandListFooter({ children }: CommandListFooterProps) {
return (
<div className="border-t px-3 py-1.5">
{children}
</div>
);
}
// ── Footer action button ────────────────────────────────────────────
interface CommandListFooterActionProps {
onClick?: () => void;
children: ReactNode;
}
export function CommandListFooterAction({ onClick, children }: CommandListFooterActionProps) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="flex w-full items-center justify-start gap-2 rounded-none px-0 py-1 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-transparent h-auto transition-colors"
onClick={onClick}
>
{children}
</Button>
);
}
// ── Empty state ─────────────────────────────────────────────────────
interface CommandListEmptyProps {
children: ReactNode;
}
export function CommandListEmpty({ children }: CommandListEmptyProps) {
return (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{children}
</div>
);
}
+4 -4
View File
@@ -2,10 +2,10 @@ import { type ReactElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
const variantStyles = {
success: "bg-green-500/10 text-green-600 dark:text-green-400",
warning: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
error: "bg-red-500/10 text-red-600 dark:text-red-400",
info: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
success: "bg-status-success-muted text-status-success-foreground",
warning: "bg-status-warning-muted text-status-warning-foreground",
error: "bg-status-error-muted text-status-error-foreground",
info: "bg-status-info-muted text-status-info-foreground",
} as const;
interface StatusBannerProps {
+10
View File
@@ -4,3 +4,13 @@ export { SectionPage } from "./SectionPage";
export { StatusBanner } from "./StatusBanner";
export { EmptyState } from "./EmptyState";
export { IconAction } from "./IconAction";
export {
CommandList,
CommandListSearch,
CommandListItems,
CommandListItem,
CommandListGroup,
CommandListFooter,
CommandListFooterAction,
CommandListEmpty,
} from "./CommandList";
+5 -5
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Plus, List, Code, SpinnerGap, ArrowsClockwise, WifiHigh } from "@phosphor-icons/react";
import { Plus, List, Code, SpinnerGap, ArrowsClockwise, WifiHigh } from "@/components/ui/icon";
import { McpServerList } from "@/components/plugins/McpServerList";
import { McpServerEditor } from "@/components/plugins/McpServerEditor";
import { ConfigEditor } from "@/components/plugins/ConfigEditor";
@@ -265,7 +265,7 @@ export function McpManager() {
Object.entries(servers)
.filter(([, v]) => v._source !== 'claude.json')
.map(([k, v]) => {
const { _source, ...rest } = v;
const { _source: _unused, ...rest } = v; // eslint-disable-line @typescript-eslint/no-unused-vars
return [k, rest];
})
),
@@ -311,11 +311,11 @@ export function McpManager() {
<div key={s.name} className="flex items-center justify-between py-1.5 px-2 rounded-md bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
<span className={`h-2 w-2 rounded-full shrink-0 ${
s.status === 'connected' ? 'bg-green-500' :
s.status === 'failed' ? 'bg-red-500' :
s.status === 'connected' ? 'bg-status-success' :
s.status === 'failed' ? 'bg-status-error' :
s.status === 'pending' ? 'bg-primary' :
s.status === 'disabled' ? 'bg-gray-400' :
'bg-yellow-500'
'bg-status-warning'
}`} />
<span className="text-xs font-medium truncate">{s.name}</span>
</div>
+2 -2
View File
@@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
@@ -13,7 +13,7 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { HardDrives, WifiHigh, Globe, Code } from "@phosphor-icons/react";
import { HardDrives, WifiHigh, Globe, Code } from "@/components/ui/icon";
import { useTranslation } from '@/hooks/useTranslation';
import type { MCPServer } from '@/types';
+5 -5
View File
@@ -3,7 +3,7 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Trash, PencilSimple, HardDrives, WifiHigh, Globe, ArrowsClockwise, SpinnerGap } from "@phosphor-icons/react";
import { Trash, PencilSimple, HardDrives, WifiHigh, Globe, ArrowsClockwise, SpinnerGap } from "@/components/ui/icon";
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { MCPServer } from '@/types';
@@ -29,7 +29,7 @@ function getServerTypeInfo(server: MCPServer) {
case 'sse':
return { label: 'SSE', icon: WifiHigh, color: 'text-primary' };
case 'http':
return { label: 'HTTP', icon: Globe, color: 'text-green-500' };
return { label: 'HTTP', icon: Globe, color: 'text-status-success-foreground' };
default:
return { label: 'stdio', icon: HardDrives, color: 'text-muted-foreground' };
}
@@ -38,11 +38,11 @@ function getServerTypeInfo(server: MCPServer) {
function getStatusBadge(status: McpRuntimeStatus['status']) {
switch (status) {
case 'connected':
return { label: 'Connected', className: 'bg-green-500/10 text-green-600 border-green-500/20' };
return { label: 'Connected', className: 'bg-status-success-muted text-status-success-foreground border-status-success-border' };
case 'failed':
return { label: 'Failed', className: 'bg-red-500/10 text-red-600 border-red-500/20' };
return { label: 'Failed', className: 'bg-status-error-muted text-status-error-foreground border-status-error-border' };
case 'needs-auth':
return { label: 'Auth Required', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' };
return { label: 'Auth Required', className: 'bg-status-warning-muted text-status-warning-foreground border-status-warning-border' };
case 'pending':
return { label: 'Pending', className: 'bg-primary/10 text-primary border-primary/20' };
case 'disabled':
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { CaretDown, CaretUp, Lightning } from "@phosphor-icons/react";
import { CaretDown, CaretUp, Lightning } from "@/components/ui/icon";
export interface SkillInfo {
name: string;
+1 -1
View File
@@ -3,7 +3,7 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { X, Lightning } from "@phosphor-icons/react";
import { X, Lightning } from "@/components/ui/icon";
import type { SkillInfo } from './PluginCard';
interface PluginDetailProps {
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PluginCard, type SkillInfo } from './PluginCard';
import { MagnifyingGlass, Globe, FolderOpen, Plug } from "@phosphor-icons/react";
import { MagnifyingGlass, Globe, FolderOpen, Plug } from "@/components/ui/icon";
interface PluginListProps {
plugins: SkillInfo[];
+3 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { ArrowLeft, Copy, Check, SpinnerGap } from "@phosphor-icons/react";
import { ArrowLeft, Copy, Check, SpinnerGap } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -57,7 +57,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
}
loadPreview();
}, [filePath]);
}, [filePath, t, workingDirectory]);
const handleCopyPath = async () => {
await navigator.clipboard.writeText(filePath);
@@ -65,9 +65,6 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
setTimeout(() => setCopied(false), 2000);
};
// Extract filename from path
const fileName = filePath.split("/").pop() || filePath;
// Build breadcrumb segments
const segments = filePath.split("/").filter(Boolean);
const displaySegments = segments.slice(-3); // show last 3 segments
@@ -87,7 +84,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
</div>
<Button variant="ghost" size="icon-sm" onClick={handleCopyPath}>
{copied ? (
<Check size={14} className="text-green-500" />
<Check size={14} className="text-status-success-foreground" />
) : (
<Copy size={14} />
)}
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { ArrowsClockwise, MagnifyingGlass, FileCode, Code, File } from "@phosphor-icons/react";
import { ArrowsClockwise, MagnifyingGlass, FileCode, Code, File } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
+12 -9
View File
@@ -1,8 +1,9 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Trash } from "@phosphor-icons/react";
import { Trash } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn, parseDBDate } from "@/lib/utils";
import type { TaskItem, TaskStatus } from "@/types";
@@ -13,10 +14,10 @@ interface TaskCardProps {
}
const statusColors: Record<TaskStatus, string> = {
pending: "bg-yellow-500",
pending: "bg-status-warning",
in_progress: "bg-primary",
completed: "bg-green-500",
failed: "bg-red-500",
completed: "bg-status-success",
failed: "bg-status-error",
};
const nextStatus: Record<TaskStatus, TaskStatus> = {
@@ -73,9 +74,11 @@ export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
onMouseLeave={() => setHovered(false)}
>
{/* Status dot */}
<button
<Button
variant="ghost"
size="icon"
onClick={handleStatusClick}
className="mt-1 shrink-0"
className="mt-1 shrink-0 h-auto w-auto p-0"
title={`Status: ${task.status} (click to change)`}
>
<span
@@ -84,12 +87,12 @@ export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
statusColors[task.status]
)}
/>
</button>
</Button>
{/* Content */}
<div className="min-w-0 flex-1">
{editing ? (
<input
<Input
ref={inputRef}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
@@ -101,7 +104,7 @@ export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
setEditing(false);
}
}}
className="w-full rounded border-none bg-transparent p-0 text-xs font-medium outline-none ring-1 ring-ring/30 px-1"
className="w-full border-none bg-transparent p-0 text-xs font-medium px-1 h-auto"
/>
) : (
<p
+5 -3
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/hooks/useTranslation";
import type { TaskItem, TaskStatus } from "@/types";
@@ -79,9 +80,10 @@ export function TaskList({ sessionId }: TaskListProps) {
{tasks.map((task) => {
const isDone = task.status === "completed";
return (
<button
<Button
key={task.id}
className="flex items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent/50 transition-colors"
variant="ghost"
className="flex items-center gap-2 justify-start px-1 py-1 text-left h-auto w-full"
onClick={() => handleToggle(task)}
>
<span
@@ -106,7 +108,7 @@ export function TaskList({ sessionId }: TaskListProps) {
>
{task.title}
</span>
</button>
</Button>
);
})}
</div>
@@ -9,7 +9,7 @@ import {
resolveShikiThemes,
} from "@/lib/theme/code-themes";
import { useTranslation } from "@/hooks/useTranslation";
import { Sun, Moon, Desktop } from "@phosphor-icons/react";
import { Sun, Moon, Desktop } from "@/components/ui/icon";
import {
Select,
SelectContent,
@@ -4,74 +4,14 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { getLocalDateString } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SpinnerGap, CheckCircle, X } from "@phosphor-icons/react";
import { Input } from "@/components/ui/input";
import { SpinnerGap, CheckCircle, X } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import type { WorkspaceInspectResult } from "@/types";
interface FileStatus {
exists: boolean;
chars: number;
preview: string;
}
interface WorkspaceState {
onboardingComplete: boolean;
lastCheckInDate: string | null;
schemaVersion: number;
}
interface TaxonomyCategoryInfo {
id: string;
label: string;
role: string;
confidence: number;
source: string;
paths: string[];
}
interface IndexStats {
fileCount: number;
chunkCount: number;
lastIndexed: number;
staleCount: number;
}
interface WorkspaceInfo {
path: string | null;
valid?: boolean;
reason?: string;
exists?: boolean;
files: Record<string, FileStatus>;
state: WorkspaceState | null;
}
const FILE_LABELS: Record<string, string> = {
claude: "claude.md",
soul: "soul.md",
user: "user.md",
memory: "memory.md",
};
type TabId = 'files' | 'taxonomy' | 'index' | 'organize';
type PathValidationStatus = 'idle' | 'checking' | 'valid' | 'invalid';
type ConfirmDialogType =
| { kind: 'not_found' }
| { kind: 'empty' }
| { kind: 'normal_directory' }
| { kind: 'existing_workspace'; summary: NonNullable<WorkspaceInspectResult['summary']> }
| { kind: 'partial_workspace' };
import { FilesTabPanel, TaxonomyTabPanel, IndexTabPanel, OrganizeTabPanel } from "./WorkspaceTabPanels";
import { WorkspaceConfirmDialogs, type ConfirmDialogType } from "./WorkspaceConfirmDialogs";
import { OnboardingCard, CheckInCard } from "./WorkspaceStatusCards";
import type { TaxonomyCategoryInfo, IndexStats, WorkspaceInfo, TabId, PathValidationStatus } from "./workspace-types";
export function AssistantWorkspaceSection() {
const { t } = useTranslation();
@@ -87,13 +27,9 @@ export function AssistantWorkspaceSection() {
const [indexStats, setIndexStats] = useState<IndexStats | null>(null);
const [reindexing, setReindexing] = useState(false);
const [archiving, setArchiving] = useState(false);
// Path validation state
const [pathValidation, setPathValidation] = useState<PathValidationStatus>('idle');
const [pathError, setPathError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogType | null>(null);
const [inspecting, setInspecting] = useState(false);
@@ -162,7 +98,6 @@ export function AssistantWorkspaceSection() {
}
const data: WorkspaceInspectResult = await res.json();
if (!data.exists) {
// Non-existent path is allowed — user can create it via initialize
setPathValidation('valid');
} else if (!data.isDirectory) {
setPathValidation('invalid');
@@ -196,7 +131,6 @@ export function AssistantWorkspaceSection() {
}, [validatePath]);
// Execute the actual save + optional auto-navigate
// navigateMode: 'new' = always create new session, 'reuse' = try to reuse latest session
const executeSave = useCallback(async (initialize: boolean, resetOnboarding?: boolean, navigateMode: 'new' | 'reuse' = 'new') => {
if (!pathInput.trim()) return;
const oldPath = workspace?.path || null;
@@ -213,14 +147,12 @@ export function AssistantWorkspaceSection() {
if (res.ok) {
await fetchWorkspace();
// If path actually changed, dispatch event
if (oldPath && oldPath !== newPath) {
window.dispatchEvent(new CustomEvent('assistant-workspace-switched', {
detail: { oldPath, newPath },
}));
}
// Navigate to assistant session
try {
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
@@ -247,14 +179,10 @@ export function AssistantWorkspaceSection() {
}
}, [pathInput, fetchWorkspace, workspace?.path, router]);
// Inspect path and show confirmation dialog (or save directly if path unchanged)
// Inspect path and show confirmation dialog
const handleSaveClick = useCallback(async () => {
if (!pathInput.trim()) return;
// If path hasn't changed, no-op
if (pathInput.trim() === workspace?.path) {
return;
}
if (pathInput.trim() === workspace?.path) return;
setInspecting(true);
try {
@@ -266,12 +194,10 @@ export function AssistantWorkspaceSection() {
}
const data: WorkspaceInspectResult = await res.json();
// Non-existent path — offer to create it
if (!data.exists) {
setConfirmDialog({ kind: 'not_found' });
return;
}
// Check for invalid states — show error, block save
if (!data.isDirectory) {
setPathValidation('invalid');
setPathError(t('assistant.pathNotDirectory'));
@@ -288,7 +214,6 @@ export function AssistantWorkspaceSection() {
return;
}
// Show confirmation dialog based on workspace status
switch (data.workspaceStatus) {
case 'empty':
setConfirmDialog({ kind: 'empty' });
@@ -316,7 +241,7 @@ export function AssistantWorkspaceSection() {
} finally {
setInspecting(false);
}
}, [pathInput, workspace?.path, executeSave, t]);
}, [pathInput, workspace?.path, t]);
const handleSelectFolder = useCallback(async () => {
try {
@@ -349,7 +274,7 @@ export function AssistantWorkspaceSection() {
}
}, []);
const handleStartOnboarding = useCallback(async () => {
const handleStartSession = useCallback(async (mode: 'onboarding' | 'checkin') => {
if (!workspace?.path) return;
setCreatingSession(true);
try {
@@ -358,7 +283,7 @@ export function AssistantWorkspaceSection() {
const res = await fetch("/api/workspace/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: 'onboarding', model, provider_id }),
body: JSON.stringify({ mode, model, provider_id }),
});
if (res.ok) {
const data = await res.json();
@@ -366,34 +291,14 @@ export function AssistantWorkspaceSection() {
router.push(`/chat/${data.session.id}`);
}
} catch (e) {
console.error("Failed to create onboarding session:", e);
console.error(`Failed to create ${mode} session:`, e);
} finally {
setCreatingSession(false);
}
}, [workspace?.path, router]);
const handleStartCheckIn = useCallback(async () => {
if (!workspace?.path) return;
setCreatingSession(true);
try {
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
const res = await fetch("/api/workspace/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: 'checkin', model, provider_id }),
});
if (res.ok) {
const data = await res.json();
window.dispatchEvent(new CustomEvent("session-created"));
router.push(`/chat/${data.session.id}`);
}
} catch (e) {
console.error("Failed to create check-in session:", e);
} finally {
setCreatingSession(false);
}
}, [workspace?.path, router]);
const handleStartOnboarding = useCallback(() => handleStartSession('onboarding'), [handleStartSession]);
const handleStartCheckIn = useCallback(() => handleStartSession('checkin'), [handleStartSession]);
const handleReindex = useCallback(async () => {
setReindexing(true);
@@ -446,9 +351,9 @@ export function AssistantWorkspaceSection() {
case 'checking':
return <SpinnerGap size={16} className="animate-spin text-muted-foreground" />;
case 'valid':
return <CheckCircle size={16} className="text-green-500" />;
return <CheckCircle size={16} className="text-status-success-foreground" />;
case 'invalid':
return <X size={16} className="text-red-500" />;
return <X size={16} className="text-status-error-foreground" />;
default:
return null;
}
@@ -462,12 +367,12 @@ export function AssistantWorkspaceSection() {
<p className="text-xs text-muted-foreground mt-1">{t('assistant.workspacePathHint')}</p>
<div className="flex items-center gap-2 mt-3">
<div className="relative flex-1">
<input
<Input
type="text"
value={pathInput}
onChange={(e) => handlePathInputChange(e.target.value)}
placeholder="/path/to/workspace"
className="w-full rounded-md border border-border/50 bg-background px-3 py-1.5 text-sm pr-8"
className="pr-8"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{renderValidationIcon()}
@@ -478,7 +383,7 @@ export function AssistantWorkspaceSection() {
</Button>
</div>
{pathError && (
<p className="text-xs text-red-500 mt-1">{pathError}</p>
<p className="text-xs text-status-error-foreground mt-1">{pathError}</p>
)}
<div className="flex items-center gap-2 mt-2">
<Button
@@ -500,8 +405,8 @@ export function AssistantWorkspaceSection() {
{/* Invalid workspace path warning */}
{workspace?.path && workspace.valid === false && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-4">
<p className="text-sm text-red-600 dark:text-red-400">
<div className="rounded-lg border border-status-error-border bg-status-error-muted p-4">
<p className="text-sm text-status-error-foreground">
{t('assistant.workspaceInvalid')}: {workspace.reason === 'path_not_found'
? t('assistant.pathNotExist')
: workspace.reason === 'not_a_directory'
@@ -518,69 +423,21 @@ export function AssistantWorkspaceSection() {
{/* Onboarding Status Card */}
{workspace?.path && workspace.valid !== false && (
<div className="rounded-lg border border-border/50 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('assistant.onboardingTitle')}</h2>
<p className="text-xs text-muted-foreground mt-1">{t('assistant.onboardingDesc')}</p>
<p className="text-xs mt-1">
{workspace.state?.onboardingComplete
? <span className="text-green-600">{t('assistant.onboardingComplete')}</span>
: <span className="text-yellow-600">{t('assistant.onboardingNotStarted')}</span>
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleStartOnboarding}
disabled={creatingSession}
>
{creatingSession ? (
<SpinnerGap size={14} className="animate-spin" />
) : workspace.state?.onboardingComplete
? t('assistant.redoOnboarding')
: t('assistant.startOnboarding')
}
</Button>
</div>
</div>
<OnboardingCard
onboardingComplete={!!workspace.state?.onboardingComplete}
creatingSession={creatingSession}
onStartOnboarding={handleStartOnboarding}
/>
)}
{/* Daily Check-in Card — only shown when onboarding is complete */}
{/* Daily Check-in Card */}
{workspace?.path && workspace.valid !== false && workspace.state?.onboardingComplete && (
<div className="rounded-lg border border-border/50 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('assistant.checkInTitle')}</h2>
<p className="text-xs text-muted-foreground mt-1">{t('assistant.checkInDesc')}</p>
<p className="text-xs mt-1">
{workspace.state?.lastCheckInDate && (
<span className="text-muted-foreground">
{t('assistant.lastCheckIn')}: {workspace.state.lastCheckInDate}
</span>
)}
{" "}
{checkInDoneToday
? <span className="text-green-600">{t('assistant.checkInToday')}</span>
: <span className="text-yellow-600">{t('assistant.checkInNeeded')}</span>
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleStartCheckIn}
disabled={creatingSession}
>
{creatingSession ? (
<SpinnerGap size={14} className="animate-spin" />
) : (
t('assistant.startCheckIn')
)}
</Button>
</div>
</div>
<CheckInCard
lastCheckInDate={workspace.state?.lastCheckInDate ?? null}
checkInDoneToday={checkInDoneToday}
creatingSession={creatingSession}
onStartCheckIn={handleStartCheckIn}
/>
)}
{/* Tabbed Section: Files / Taxonomy / Index / Organize */}
@@ -588,321 +445,55 @@ export function AssistantWorkspaceSection() {
<div className="rounded-lg border border-border/50 p-4">
<div className="flex gap-1 border-b border-border/50 mb-3">
{tabs.map(tab => (
<button
<Button
key={tab.id}
variant="ghost"
size="sm"
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
className={`px-3 py-1.5 text-xs font-medium rounded-t rounded-b-none h-auto ${
activeTab === tab.id
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
</Button>
))}
</div>
{/* Files Tab */}
{activeTab === 'files' && (
<div className="space-y-2">
{Object.entries(FILE_LABELS).map(([key, label]) => {
const file = workspace.files[key];
return (
<div key={key} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs">{label}</span>
<div className="flex items-center gap-2">
{file?.exists ? (
<>
<span className="text-xs text-muted-foreground">
{t('assistant.fileChars', { count: String(file.chars) })}
</span>
<span className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-xs text-green-600">{t('assistant.fileExists')}</span>
</>
) : (
<>
<span className="h-2 w-2 rounded-full bg-yellow-500" />
<span className="text-xs text-yellow-600">{t('assistant.fileMissing')}</span>
</>
)}
</div>
</div>
);
})}
<div className="flex items-center justify-end mt-3">
<Button
variant="outline"
size="sm"
onClick={handleRefreshDocs}
disabled={refreshingDocs}
>
{refreshingDocs ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.refreshingDocs')}
</>
) : (
t('assistant.refreshDocs')
)}
</Button>
</div>
</div>
<FilesTabPanel
files={workspace.files}
refreshingDocs={refreshingDocs}
onRefreshDocs={handleRefreshDocs}
/>
)}
{/* Taxonomy Tab */}
{activeTab === 'taxonomy' && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('assistant.taxonomyDesc')}</p>
{taxonomy.length === 0 ? (
<p className="text-xs text-muted-foreground italic">{t('assistant.taxonomyEmpty')}</p>
) : (
<div className="space-y-1.5">
{taxonomy.map(cat => (
<div key={cat.id} className="flex items-center justify-between text-xs border border-border/30 rounded px-2 py-1.5">
<div>
<span className="font-medium">{cat.label}</span>
<span className="text-muted-foreground ml-2">{t('assistant.taxonomyRole')}: {cat.role}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{t('assistant.taxonomySource')}: {cat.source}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] ${
cat.confidence > 0.7 ? 'bg-green-500/10 text-green-600' :
cat.confidence > 0.4 ? 'bg-yellow-500/10 text-yellow-600' :
'bg-red-500/10 text-red-600'
}`}>
{Math.round(cat.confidence * 100)}%
</span>
</div>
</div>
))}
</div>
)}
</div>
<TaxonomyTabPanel taxonomy={taxonomy} />
)}
{/* Index Tab */}
{activeTab === 'index' && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('assistant.indexDesc')}</p>
{indexStats ? (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">{t('assistant.indexFiles', { count: String(indexStats.fileCount) })}</span>
</div>
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">{t('assistant.indexChunks', { count: String(indexStats.chunkCount) })}</span>
</div>
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">{t('assistant.indexStale', { count: String(indexStats.staleCount) })}</span>
</div>
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">
{t('assistant.indexLastIndexed')}: {indexStats.lastIndexed ? new Date(indexStats.lastIndexed).toLocaleString() : 'never'}
</span>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground italic">{t('common.loading')}</p>
)}
<div className="flex items-center justify-end mt-2">
<Button
variant="outline"
size="sm"
onClick={handleReindex}
disabled={reindexing}
>
{reindexing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.indexReindexing')}
</>
) : (
t('assistant.indexReindex')
)}
</Button>
</div>
</div>
<IndexTabPanel
indexStats={indexStats}
reindexing={reindexing}
onReindex={handleReindex}
/>
)}
{/* Organize Tab */}
{activeTab === 'organize' && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('assistant.organizeDesc')}</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleArchive}
disabled={archiving}
>
{archiving ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.organizeArchiving')}
</>
) : (
t('assistant.organizeArchive')
)}
</Button>
</div>
</div>
<OrganizeTabPanel
archiving={archiving}
onArchive={handleArchive}
/>
)}
</div>
)}
{/* ── Confirmation Dialogs ── */}
{/* Non-existent path — offer to create */}
<AlertDialog open={confirmDialog?.kind === 'not_found'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmNotFoundTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('assistant.confirmNotFoundDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmCreate')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Empty directory confirmation */}
<AlertDialog open={confirmDialog?.kind === 'empty'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmEmptyTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('assistant.confirmEmptyDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmInitialize')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Normal directory confirmation */}
<AlertDialog open={confirmDialog?.kind === 'normal_directory'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmNormalTitle')}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>{t('assistant.confirmNormalDesc')}</p>
<p className="text-xs text-muted-foreground">{t('assistant.confirmNormalHint')}</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmInitialize')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Existing workspace confirmation */}
<AlertDialog open={confirmDialog?.kind === 'existing_workspace'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmExistingTitle')}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>{t('assistant.confirmExistingDesc')}</p>
{confirmDialog?.kind === 'existing_workspace' && (
<div className="rounded border border-border/50 p-3 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('assistant.summaryOnboarding')}:</span>
<span>{confirmDialog.summary.onboardingComplete
? t('assistant.onboardingComplete')
: t('assistant.onboardingNotStarted')
}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('assistant.summaryLastCheckIn')}:</span>
<span>{confirmDialog.summary.lastCheckInDate || t('assistant.summaryNever')}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('assistant.summaryFileCount')}:</span>
<span>{confirmDialog.summary.fileCount}</span>
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-col gap-2">
<Button size="sm" onClick={() => executeSave(false, false, 'reuse')} disabled={initializing}>
{t('assistant.takeoverContinue')}
</Button>
<Button size="sm" variant="outline" onClick={() => executeSave(false, true, 'new')} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.takeoverReonboard')
)}
</Button>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Partial workspace confirmation */}
<AlertDialog open={confirmDialog?.kind === 'partial_workspace'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmPartialTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('assistant.confirmPartialDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmRepair')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirmation Dialogs */}
<WorkspaceConfirmDialogs
confirmDialog={confirmDialog}
initializing={initializing}
onClose={() => setConfirmDialog(null)}
onExecuteSave={executeSave}
/>
</div>
);
}
@@ -23,7 +23,7 @@ import {
Code,
SlidersHorizontal,
SpinnerGap,
} from "@phosphor-icons/react";
} from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
@@ -266,7 +266,7 @@ export function CliSettingsSection() {
{t('cli.reset')}
</Button>
{saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400">
<span className="text-sm text-status-success-foreground">
{t('cli.settingsSaved')}
</span>
)}
@@ -305,7 +305,7 @@ export function CliSettingsSection() {
{t('cli.reset')}
</Button>
{saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400">
<span className="text-sm text-status-success-foreground">
{t('cli.settingsSaved')}
</span>
)}
+7 -7
View File
@@ -13,7 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ArrowClockwise, SpinnerGap } from "@phosphor-icons/react";
import { ArrowClockwise, SpinnerGap } from "@/components/ui/icon";
import { useUpdate } from "@/hooks/useUpdate";
import { useTranslation } from "@/hooks/useTranslation";
import { useAccountInfo } from "@/hooks/useAccountInfo";
@@ -79,7 +79,7 @@ function UpdateCard() {
{updateInfo.updateAvailable ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-primary'}`} />
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-status-success' : isDownloading ? 'bg-status-warning animate-pulse' : 'bg-primary'}`} />
<span className="text-sm">
{updateInfo.readyToInstall
? t('update.readyToInstall', { version: updateInfo.latestVersion })
@@ -108,7 +108,7 @@ function UpdateCard() {
</div>
)}
{updateInfo.lastError && (
<p className="text-xs text-red-600 dark:text-red-400">
<p className="text-xs text-status-error-foreground">
{updateInfo.lastError}
</p>
)}
@@ -199,7 +199,7 @@ export function GeneralSection() {
<UpdateCard />
{/* General settings card */}
<SettingsCard className={skipPermissions ? "border-orange-500/50 bg-orange-500/5" : undefined}>
<SettingsCard className={skipPermissions ? "border-status-warning-border bg-status-warning-muted" : undefined}>
{/* Auto-approve toggle */}
<FieldRow
label={t('settings.autoApproveTitle')}
@@ -213,7 +213,7 @@ export function GeneralSection() {
</FieldRow>
{skipPermissions && (
<StatusBanner variant="warning">
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500 inline-block mr-1" />
<span className="h-2 w-2 shrink-0 rounded-full bg-status-warning inline-block mr-1" />
{t('settings.autoApproveWarning')}
</StatusBanner>
)}
@@ -296,7 +296,7 @@ export function GeneralSection() {
<li>{t('settings.autoApproveFileOps')}</li>
<li>{t('settings.autoApproveNetwork')}</li>
</ul>
<p className="font-medium text-orange-600 dark:text-orange-400">
<p className="font-medium text-status-warning-foreground">
{t('settings.autoApproveTrustWarning')}
</p>
</div>
@@ -306,7 +306,7 @@ export function GeneralSection() {
<AlertDialogCancel>{t('settings.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => saveSkipPermissions(true)}
className="bg-orange-600 hover:bg-orange-700 text-white"
className="bg-status-warning hover:bg-status-warning/80 text-white"
>
{t('settings.enableAutoApprove')}
</AlertDialogAction>
@@ -0,0 +1,503 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { SpinnerGap, CaretDown, CaretUp } from "@/components/ui/icon";
import type { ProviderFormData } from "./ProviderForm";
import type { QuickPreset } from "./provider-presets";
import type { ApiProvider } from "@/types";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
interface PresetConnectDialogProps {
preset: QuickPreset | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (data: ProviderFormData) => Promise<void>;
/** When set, dialog operates in edit mode (pre-fills from existing provider) */
editProvider?: ApiProvider | null;
}
export function PresetConnectDialog({
preset,
open,
onOpenChange,
onSave,
editProvider,
}: PresetConnectDialogProps) {
const isEdit = !!editProvider;
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [name, setName] = useState("");
const [extraEnv, setExtraEnv] = useState("{}");
const [modelName, setModelName] = useState("");
// Auth style for anthropic-thirdparty: 'api_key' or 'auth_token'
const [authStyle, setAuthStyle] = useState<"api_key" | "auth_token">("api_key");
// Track the initial auth style to detect changes
const [initialAuthStyle, setInitialAuthStyle] = useState<"api_key" | "auth_token">("api_key");
// Edit-mode advanced fields
const [headersJson, setHeadersJson] = useState("{}");
const [envOverridesJson, setEnvOverridesJson] = useState("");
const [notes, setNotes] = useState("");
// Model mapping fields (sonnet/opus/haiku → actual API model IDs)
const [mapSonnet, setMapSonnet] = useState("");
const [mapOpus, setMapOpus] = useState("");
const [mapHaiku, setMapHaiku] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const { t } = useTranslation();
const isZh = t('nav.chats') === '对话';
// Reset form when dialog opens
useEffect(() => {
if (!open || !preset) return;
setError(null);
setSaving(false);
if (isEdit && editProvider) {
// Edit mode — pre-fill from existing provider
setName(editProvider.name);
setBaseUrl(editProvider.base_url);
setApiKey(editProvider.api_key || "");
setExtraEnv(editProvider.extra_env || preset.extra_env);
// Detect auth style from existing extra_env
try {
const env = JSON.parse(editProvider.extra_env || "{}");
const detected = "ANTHROPIC_AUTH_TOKEN" in env ? "auth_token" as const : "api_key" as const;
setAuthStyle(detected);
setInitialAuthStyle(detected);
} catch {
setAuthStyle("api_key");
setInitialAuthStyle("api_key");
}
// Pre-fill advanced fields
setHeadersJson(editProvider.headers_json || "{}");
setEnvOverridesJson(editProvider.env_overrides_json || "");
setNotes(editProvider.notes || "");
// Pre-fill model name from role_models_json
try {
const rm = JSON.parse(editProvider.role_models_json || "{}");
setModelName(rm.default || "");
setMapSonnet(rm.sonnet || "");
setMapOpus(rm.opus || "");
setMapHaiku(rm.haiku || "");
} catch {
setModelName("");
setMapSonnet("");
setMapOpus("");
setMapHaiku("");
}
// Auto-expand advanced if there's meaningful data beyond preset defaults
const hasModelMapping = (() => {
try {
const rm = JSON.parse(editProvider.role_models_json || "{}");
return !!(rm.sonnet || rm.opus || rm.haiku);
} catch { return false; }
})();
const hasExtraEnvBeyondAuth = (() => {
try {
const env = JSON.parse(editProvider.extra_env || "{}");
const meaningful = Object.keys(env).filter(k =>
k !== "ANTHROPIC_API_KEY" && k !== "ANTHROPIC_AUTH_TOKEN"
);
return meaningful.length > 0;
} catch { return false; }
})();
const hasHeaders = editProvider.headers_json && editProvider.headers_json !== "{}";
const hasEnvOverrides = !!editProvider.env_overrides_json;
const hasNotes = !!editProvider.notes;
setShowAdvanced(hasModelMapping || hasExtraEnvBeyondAuth || !!hasHeaders || hasEnvOverrides || hasNotes);
} else {
// Create mode — reset to preset defaults
setApiKey("");
setBaseUrl(preset.base_url);
setName(preset.name);
setExtraEnv(preset.extra_env);
setModelName("");
setAuthStyle("api_key");
setInitialAuthStyle("api_key");
setMapSonnet("");
setMapOpus("");
setMapHaiku("");
setHeadersJson("{}");
setEnvOverridesJson("");
setNotes("");
setShowAdvanced(false);
}
}, [open, preset, isEdit, editProvider]);
if (!preset) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// If auth style changed in edit mode, require a new key
if (isEdit && authStyle !== initialAuthStyle && (!apiKey || apiKey.startsWith("***"))) {
setError(isZh
? '切换认证方式后需要重新输入密钥'
: 'Please re-enter the key after changing auth style');
return;
}
// For anthropic-thirdparty, inject the correct auth key into extra_env
// while preserving any other user-specified env vars (e.g. API_TIMEOUT_MS)
let finalExtraEnv = extraEnv;
if (preset.key === "anthropic-thirdparty") {
try {
const parsed = JSON.parse(extraEnv || "{}");
// Remove both auth keys, then set the correct one
delete parsed["ANTHROPIC_API_KEY"];
delete parsed["ANTHROPIC_AUTH_TOKEN"];
if (authStyle === "auth_token") {
parsed["ANTHROPIC_AUTH_TOKEN"] = "";
} else {
parsed["ANTHROPIC_API_KEY"] = "";
}
finalExtraEnv = JSON.stringify(parsed);
} catch {
// If parse fails, fall back to simple replacement
finalExtraEnv = authStyle === "auth_token"
? '{"ANTHROPIC_AUTH_TOKEN":""}'
: '{"ANTHROPIC_API_KEY":""}';
}
}
// In edit mode, preserve existing role_models_json unless the user modifies mapping fields
let roleModelsJson = (isEdit && editProvider?.role_models_json) ? editProvider.role_models_json : "{}";
// Model mapping (sonnet/opus/haiku → actual API model IDs)
// Merge into existing roleModels to preserve roles not shown in this preset.
// If the preset exposes these fields and user cleared them all, remove those keys.
if (preset.fields.includes("model_mapping")) {
const hasAny = mapSonnet.trim() || mapOpus.trim() || mapHaiku.trim();
if (hasAny) {
// If user fills any, all 3 are required
if (!mapSonnet.trim() || !mapOpus.trim() || !mapHaiku.trim()) {
setError(isZh
? '模型映射需要同时填写 Sonnet、Opus、Haiku 三个模型名称'
: 'Model mapping requires all 3 model names (Sonnet, Opus, Haiku)');
return;
}
const existing = (() => { try { return JSON.parse(roleModelsJson); } catch { return {}; } })();
roleModelsJson = JSON.stringify({
...existing,
sonnet: mapSonnet.trim(),
opus: mapOpus.trim(),
haiku: mapHaiku.trim(),
});
} else {
// All cleared — remove these keys from existing
const existing = (() => { try { return JSON.parse(roleModelsJson); } catch { return {}; } })();
delete existing.sonnet;
delete existing.opus;
delete existing.haiku;
roleModelsJson = JSON.stringify(existing);
}
}
// Inject model name into role_models_json — merge, don't replace.
// If the preset exposes model_names and user cleared it, remove the default key.
if (preset.fields.includes("model_names")) {
const existing = (() => { try { return JSON.parse(roleModelsJson); } catch { return {}; } })();
if (modelName.trim()) {
roleModelsJson = JSON.stringify({ ...existing, default: modelName.trim() });
} else {
delete existing.default;
roleModelsJson = JSON.stringify(existing);
}
}
// Validate JSON fields
for (const [label, val] of [
["Extra environment variables", finalExtraEnv],
...(isEdit ? [["Headers", headersJson]] : []),
] as const) {
if (val && val.trim()) {
try { JSON.parse(val); } catch {
setError(`${label} must be valid JSON`);
return;
}
}
}
setSaving(true);
try {
await onSave({
name: name.trim() || preset.name,
provider_type: preset.provider_type,
protocol: preset.protocol,
base_url: baseUrl.trim(),
api_key: apiKey,
extra_env: finalExtraEnv,
role_models_json: roleModelsJson,
headers_json: isEdit ? headersJson.trim() || "{}" : undefined,
env_overrides_json: isEdit ? envOverridesJson.trim() || "" : undefined,
notes: isEdit ? notes.trim() : "",
});
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : (isEdit ? "Failed to update provider" : "Failed to add provider"));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[28rem]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2.5">
{preset.icon}
{isEdit ? t('provider.editProvider') : t('provider.connect')} {preset.name}
</DialogTitle>
<DialogDescription>
{isZh ? preset.descriptionZh : preset.description}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 min-w-0">
{/* Name field — custom/thirdparty */}
{preset.fields.includes("name") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={preset.name}
className="text-sm"
/>
</div>
)}
{/* Base URL */}
{preset.fields.includes("base_url") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.baseUrl')}</Label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
className="text-sm font-mono"
/>
</div>
)}
{/* API Key with optional auth style select */}
{preset.fields.includes("api_key") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{preset.key === "anthropic-thirdparty"
? (authStyle === "auth_token" ? "Auth Token" : "API Key")
: "API Key"}
</Label>
<div className="flex gap-2">
{preset.key === "anthropic-thirdparty" && (
<Select
value={authStyle}
onValueChange={(v) => {
const newStyle = v as "api_key" | "auth_token";
setAuthStyle(newStyle);
if (isEdit && editProvider?.api_key) {
if (newStyle !== initialAuthStyle) {
// Switching away — clear masked key to force re-entry
setApiKey("");
} else {
// Switching back to original — restore masked key
setApiKey(editProvider.api_key);
}
}
}}
>
<SelectTrigger className="w-[130px] shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api_key">API Key</SelectItem>
<SelectItem value="auth_token">Auth Token</SelectItem>
</SelectContent>
</Select>
)}
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={authStyle === "auth_token" ? "token-..." : "sk-..."}
className="text-sm font-mono flex-1"
autoFocus
/>
</div>
</div>
)}
{/* Model name — for providers that need user-specified model */}
{preset.fields.includes("model_names") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.modelName' as TranslationKey)}</Label>
<Input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ark-code-latest"
className="text-sm font-mono"
/>
<p className="text-[11px] text-muted-foreground">
{isZh
? '在服务商控制台配置的模型名称,如 ark-code-latest、doubao-seed-2.0-code'
: 'Model name configured in provider console, e.g. ark-code-latest'}
</p>
</div>
)}
{/* Extra env — bedrock/vertex/custom always shown */}
{preset.fields.includes("extra_env") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
<Textarea
value={extraEnv}
onChange={(e) => setExtraEnv(e.target.value)}
className="text-sm font-mono min-h-[80px]"
rows={3}
/>
</div>
)}
{/* Advanced options — for presets that don't normally show extra_env */}
{!preset.fields.includes("extra_env") && (
<>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground h-auto px-0 py-0"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <CaretUp size={12} /> : <CaretDown size={12} />}
{t('provider.advancedOptions')}
</Button>
{showAdvanced && (
<div className="space-y-4 border-t border-border/50 pt-3">
{/* Model mapping (sonnet/opus/haiku → API model IDs) */}
{preset.fields.includes("model_mapping") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{isZh ? '模型名称映射' : 'Model Name Mapping'}
</Label>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{isZh
? '如果服务商使用不同的模型名称(如 claude-sonnet-4-6),在此映射。留空则使用默认名称(sonnet / opus / haiku)。'
: 'Map model names if the provider uses different IDs (e.g. claude-sonnet-4-6). Leave empty to use defaults (sonnet / opus / haiku).'}
</p>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 items-center">
<span className="text-xs text-muted-foreground text-right">Sonnet</span>
<Input
value={mapSonnet}
onChange={(e) => setMapSonnet(e.target.value)}
placeholder="claude-sonnet-4-6"
className="text-sm font-mono h-8"
/>
<span className="text-xs text-muted-foreground text-right">Opus</span>
<Input
value={mapOpus}
onChange={(e) => setMapOpus(e.target.value)}
placeholder="claude-opus-4-6"
className="text-sm font-mono h-8"
/>
<span className="text-xs text-muted-foreground text-right">Haiku</span>
<Input
value={mapHaiku}
onChange={(e) => setMapHaiku(e.target.value)}
placeholder="claude-haiku-4-5-20251001"
className="text-sm font-mono h-8"
/>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
<Textarea
value={extraEnv}
onChange={(e) => setExtraEnv(e.target.value)}
className="text-sm font-mono min-h-[60px]"
rows={3}
/>
</div>
{/* Edit-mode only: headers, env overrides, notes */}
{isEdit && (
<>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Headers (JSON)</Label>
<Textarea
value={headersJson}
onChange={(e) => setHeadersJson(e.target.value)}
placeholder='{"X-Custom-Header": "value"}'
className="text-sm font-mono min-h-[60px]"
rows={2}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Env Overrides (JSON)</Label>
<Textarea
value={envOverridesJson}
onChange={(e) => setEnvOverridesJson(e.target.value)}
placeholder='{"CLAUDE_CODE_USE_BEDROCK": "1"}'
className="text-sm font-mono min-h-[60px]"
rows={2}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.notes')}</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('provider.notesPlaceholder')}
className="text-sm"
rows={2}
/>
</div>
</>
)}
</div>
)}
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={saving} className="gap-2">
{saving && <SpinnerGap size={16} className="animate-spin" />}
{saving ? t('provider.saving') : isEdit ? t('provider.update') : t('provider.connect')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+6 -5
View File
@@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SpinnerGap, CaretDown, CaretUp } from "@phosphor-icons/react";
import { SpinnerGap, CaretDown, CaretUp } from "@/components/ui/icon";
import type { ApiProvider } from "@/types";
import { useTranslation } from "@/hooks/useTranslation";
@@ -278,14 +278,15 @@ export function ProviderForm({
</div>
{/* Advanced options toggle */}
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-xs text-muted-foreground hover:text-foreground px-0 h-auto"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <CaretUp size={12} /> : <CaretDown size={12} />}
{t('provider.advancedOptions')}
</button>
</Button>
{showAdvanced && (
<div className="space-y-4 border-t border-border/50 pt-4">
+17 -815
View File
@@ -1,26 +1,8 @@
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -31,802 +13,21 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
SpinnerGap,
PencilSimple,
CaretDown,
CaretUp,
HardDrives,
Gear,
} from "@phosphor-icons/react";
import { SpinnerGap, PencilSimple } from "@/components/ui/icon";
import { ProviderForm } from "./ProviderForm";
import type { ProviderFormData } from "./ProviderForm";
import { PresetConnectDialog } from "./PresetConnectDialog";
import {
QUICK_PRESETS,
GEMINI_IMAGE_MODELS,
getGeminiImageModel,
getProviderIcon,
findMatchingPreset,
type QuickPreset,
} from "./provider-presets";
import type { ApiProvider } from "@/types";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
import Anthropic from "@lobehub/icons/es/Anthropic";
import OpenRouter from "@lobehub/icons/es/OpenRouter";
import Zhipu from "@lobehub/icons/es/Zhipu";
import Kimi from "@lobehub/icons/es/Kimi";
import Moonshot from "@lobehub/icons/es/Moonshot";
import Minimax from "@lobehub/icons/es/Minimax";
import Aws from "@lobehub/icons/es/Aws";
import Bedrock from "@lobehub/icons/es/Bedrock";
import Google from "@lobehub/icons/es/Google";
import Volcengine from "@lobehub/icons/es/Volcengine";
import Bailian from "@lobehub/icons/es/Bailian";
// ---------------------------------------------------------------------------
// Brand icon resolver
// ---------------------------------------------------------------------------
/** Map a provider name / base_url to a brand icon */
function getProviderIcon(name: string, baseUrl: string): ReactNode {
const lower = name.toLowerCase();
const url = baseUrl.toLowerCase();
if (lower.includes("openrouter")) return <OpenRouter size={18} />;
if (url.includes("bigmodel.cn") || url.includes("z.ai") || lower.includes("glm") || lower.includes("zhipu") || lower.includes("chatglm"))
return <Zhipu size={18} />;
if (url.includes("kimi.com") || lower.includes("kimi")) return <Kimi size={18} />;
if (url.includes("moonshot") || lower.includes("moonshot")) return <Moonshot size={18} />;
if (url.includes("minimax") || lower.includes("minimax")) return <Minimax size={18} />;
if (url.includes("volces.com") || url.includes("volcengine") || lower.includes("volcengine") || lower.includes("火山") || lower.includes("doubao") || lower.includes("豆包"))
return <Volcengine size={18} />;
if (url.includes("dashscope") || lower.includes("bailian") || lower.includes("百炼") || lower.includes("aliyun"))
return <Bailian size={18} />;
if (lower.includes("bedrock")) return <Bedrock size={18} />;
if (lower.includes("vertex") || lower.includes("google")) return <Google size={18} />;
if (lower.includes("aws")) return <Aws size={18} />;
if (lower.includes("anthropic") || url.includes("anthropic")) return <Anthropic size={18} />;
return <HardDrives size={18} className="text-muted-foreground" />;
}
// ---------------------------------------------------------------------------
// Quick-add preset definitions
// ---------------------------------------------------------------------------
interface QuickPreset {
key: string; // unique key
name: string;
description: string;
descriptionZh: string;
icon: ReactNode;
// Pre-filled provider data
provider_type: string;
/** Wire protocol — determines how the provider is dispatched at runtime */
protocol: string;
base_url: string;
extra_env: string;
// Which fields user must fill
fields: ("name" | "api_key" | "base_url" | "extra_env" | "model_names" | "model_mapping")[];
// Category: 'chat' (default) or 'media'
category?: "chat" | "media";
}
const QUICK_PRESETS: QuickPreset[] = [
// ── Custom endpoints ──
{
key: "custom-openai",
name: "Custom API (OpenAI-compatible)",
description: "OpenAI-compatible custom endpoint",
descriptionZh: "自定义 OpenAI 兼容 API 端点",
icon: <Gear size={18} className="text-muted-foreground" />,
provider_type: "custom",
protocol: "openai-compatible",
base_url: "",
extra_env: "{}",
fields: ["name", "api_key", "base_url", "extra_env"],
},
// ── Anthropic-compatible services ──
{
key: "anthropic-thirdparty",
name: "Anthropic Third-party API",
description: "Anthropic-compatible API — provide URL and Key",
descriptionZh: "Anthropic 兼容第三方 API — 填写地址和密钥",
icon: <Anthropic size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["name", "api_key", "base_url", "model_mapping"],
},
{
key: "anthropic-official",
name: "Anthropic",
description: "Official Anthropic API",
descriptionZh: "Anthropic 官方 API",
icon: <Anthropic size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.anthropic.com",
extra_env: "{}",
fields: ["api_key"],
},
{
key: "openrouter",
name: "OpenRouter",
description: "Use OpenRouter to access multiple models",
descriptionZh: "通过 OpenRouter 访问多种模型",
icon: <OpenRouter size={18} />,
provider_type: "openrouter",
protocol: "openrouter",
base_url: "https://openrouter.ai/api",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "glm-cn",
name: "GLM (CN)",
description: "Zhipu GLM Code Plan — China region",
descriptionZh: "智谱 GLM 编程套餐 — 中国区",
icon: <Zhipu size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://open.bigmodel.cn/api/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "glm-global",
name: "GLM (Global)",
description: "Zhipu GLM Code Plan — Global region",
descriptionZh: "智谱 GLM 编程套餐 — 国际区",
icon: <Zhipu size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.z.ai/api/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "kimi",
name: "Kimi Coding Plan",
description: "Kimi Coding Plan API",
descriptionZh: "Kimi 编程计划 API",
icon: <Kimi size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.kimi.com/coding/",
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
fields: ["api_key"],
},
{
key: "moonshot",
name: "Moonshot",
description: "Moonshot AI API",
descriptionZh: "月之暗面 API",
icon: <Moonshot size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.moonshot.cn/anthropic",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "minimax-cn",
name: "MiniMax (CN)",
description: "MiniMax Code Plan — China region",
descriptionZh: "MiniMax 编程套餐 — 中国区",
icon: <Minimax size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.minimaxi.com/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "minimax-global",
name: "MiniMax (Global)",
description: "MiniMax Code Plan — Global region",
descriptionZh: "MiniMax 编程套餐 — 国际区",
icon: <Minimax size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.minimax.io/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "volcengine",
name: "Volcengine Ark",
description: "Volcengine Ark Coding Plan — Doubao, GLM, DeepSeek, Kimi",
descriptionZh: "字节火山方舟 Coding Plan — 豆包、GLM、DeepSeek、Kimi",
icon: <Volcengine size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://ark.cn-beijing.volces.com/api/coding",
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
fields: ["api_key", "model_names"],
},
{
key: "bailian",
name: "Aliyun Bailian",
description: "Aliyun Bailian Coding Plan — Qwen, GLM, Kimi, MiniMax",
descriptionZh: "阿里云百炼 Coding Plan — 通义千问、GLM、Kimi、MiniMax",
icon: <Bailian size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
// ── Cloud platform providers ──
{
key: "bedrock",
name: "AWS Bedrock",
description: "Amazon Bedrock — requires AWS credentials",
descriptionZh: "Amazon Bedrock — 需要 AWS 凭证",
icon: <Bedrock size={18} />,
provider_type: "bedrock",
protocol: "bedrock",
base_url: "",
extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}',
fields: ["extra_env"],
},
{
key: "vertex",
name: "Google Vertex",
description: "Google Vertex AI — requires GCP credentials",
descriptionZh: "Google Vertex AI — 需要 GCP 凭证",
icon: <Google size={18} />,
provider_type: "vertex",
protocol: "vertex",
base_url: "",
extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}',
fields: ["extra_env"],
},
// ── Proxy / gateway ──
{
key: "litellm",
name: "LiteLLM",
description: "LiteLLM proxy — local or remote",
descriptionZh: "LiteLLM 代理 — 本地或远程",
icon: <HardDrives size={18} className="text-muted-foreground" />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "http://localhost:4000",
extra_env: "{}",
fields: ["api_key", "base_url"],
},
// ── Media providers ──
{
key: "gemini-image",
name: "Google Gemini (Image)",
description: "Nano Banana Pro — AI image generation by Google Gemini",
descriptionZh: "Nano Banana Pro — Google Gemini AI 图片生成",
icon: <Google size={18} />,
provider_type: "gemini-image",
protocol: "gemini-image",
base_url: "https://generativelanguage.googleapis.com/v1beta",
extra_env: '{"GEMINI_API_KEY":""}',
fields: ["api_key"],
category: "media",
},
];
const GEMINI_IMAGE_MODELS = [
{ value: 'gemini-3.1-flash-image-preview', label: 'Nano Banana 2' },
{ value: 'gemini-3-pro-image-preview', label: 'Nano Banana Pro' },
{ value: 'gemini-2.5-flash-image', label: 'Nano Banana' },
];
const DEFAULT_GEMINI_IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
function getGeminiImageModel(provider: ApiProvider): string {
try {
const env = JSON.parse(provider.extra_env || '{}');
return env.GEMINI_IMAGE_MODEL || DEFAULT_GEMINI_IMAGE_MODEL;
} catch {
return DEFAULT_GEMINI_IMAGE_MODEL;
}
}
// ---------------------------------------------------------------------------
// Preset connect dialog
// ---------------------------------------------------------------------------
function PresetConnectDialog({
preset,
open,
onOpenChange,
onSave,
editProvider,
}: {
preset: QuickPreset | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (data: ProviderFormData) => Promise<void>;
/** When set, dialog operates in edit mode (pre-fills from existing provider) */
editProvider?: ApiProvider | null;
}) {
const isEdit = !!editProvider;
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [name, setName] = useState("");
const [extraEnv, setExtraEnv] = useState("{}");
const [modelName, setModelName] = useState("");
// Auth style for anthropic-thirdparty: 'api_key' or 'auth_token'
const [authStyle, setAuthStyle] = useState<"api_key" | "auth_token">("api_key");
// Track the initial auth style to detect changes
const [initialAuthStyle, setInitialAuthStyle] = useState<"api_key" | "auth_token">("api_key");
// Edit-mode advanced fields
const [headersJson, setHeadersJson] = useState("{}");
const [envOverridesJson, setEnvOverridesJson] = useState("");
const [notes, setNotes] = useState("");
// Model mapping fields (sonnet/opus/haiku → actual API model IDs)
const [mapSonnet, setMapSonnet] = useState("");
const [mapOpus, setMapOpus] = useState("");
const [mapHaiku, setMapHaiku] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const { t } = useTranslation();
const isZh = t('nav.chats') === '对话';
// Reset form when dialog opens
useEffect(() => {
if (!open || !preset) return;
setError(null);
setSaving(false);
if (isEdit && editProvider) {
// Edit mode — pre-fill from existing provider
setName(editProvider.name);
setBaseUrl(editProvider.base_url);
setApiKey(editProvider.api_key || "");
setExtraEnv(editProvider.extra_env || preset.extra_env);
// Detect auth style from existing extra_env
try {
const env = JSON.parse(editProvider.extra_env || "{}");
const detected = "ANTHROPIC_AUTH_TOKEN" in env ? "auth_token" as const : "api_key" as const;
setAuthStyle(detected);
setInitialAuthStyle(detected);
} catch {
setAuthStyle("api_key");
setInitialAuthStyle("api_key");
}
// Pre-fill advanced fields
setHeadersJson(editProvider.headers_json || "{}");
setEnvOverridesJson(editProvider.env_overrides_json || "");
setNotes(editProvider.notes || "");
// Pre-fill model name from role_models_json
try {
const rm = JSON.parse(editProvider.role_models_json || "{}");
setModelName(rm.default || "");
setMapSonnet(rm.sonnet || "");
setMapOpus(rm.opus || "");
setMapHaiku(rm.haiku || "");
} catch {
setModelName("");
setMapSonnet("");
setMapOpus("");
setMapHaiku("");
}
// Auto-expand advanced if there's meaningful data beyond preset defaults
const hasModelMapping = (() => {
try {
const rm = JSON.parse(editProvider.role_models_json || "{}");
return !!(rm.sonnet || rm.opus || rm.haiku);
} catch { return false; }
})();
const hasExtraEnvBeyondAuth = (() => {
try {
const env = JSON.parse(editProvider.extra_env || "{}");
const meaningful = Object.keys(env).filter(k =>
k !== "ANTHROPIC_API_KEY" && k !== "ANTHROPIC_AUTH_TOKEN"
);
return meaningful.length > 0;
} catch { return false; }
})();
const hasHeaders = editProvider.headers_json && editProvider.headers_json !== "{}";
const hasEnvOverrides = !!editProvider.env_overrides_json;
const hasNotes = !!editProvider.notes;
setShowAdvanced(hasModelMapping || hasExtraEnvBeyondAuth || !!hasHeaders || hasEnvOverrides || hasNotes);
} else {
// Create mode — reset to preset defaults
setApiKey("");
setBaseUrl(preset.base_url);
setName(preset.name);
setExtraEnv(preset.extra_env);
setModelName("");
setAuthStyle("api_key");
setInitialAuthStyle("api_key");
setMapSonnet("");
setMapOpus("");
setMapHaiku("");
setHeadersJson("{}");
setEnvOverridesJson("");
setNotes("");
setShowAdvanced(false);
}
}, [open, preset, isEdit, editProvider]);
if (!preset) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// If auth style changed in edit mode, require a new key
if (isEdit && authStyle !== initialAuthStyle && (!apiKey || apiKey.startsWith("***"))) {
setError(isZh
? '切换认证方式后需要重新输入密钥'
: 'Please re-enter the key after changing auth style');
return;
}
// For anthropic-thirdparty, inject the correct auth key into extra_env
// while preserving any other user-specified env vars (e.g. API_TIMEOUT_MS)
let finalExtraEnv = extraEnv;
if (preset.key === "anthropic-thirdparty") {
try {
const parsed = JSON.parse(extraEnv || "{}");
// Remove both auth keys, then set the correct one
delete parsed["ANTHROPIC_API_KEY"];
delete parsed["ANTHROPIC_AUTH_TOKEN"];
if (authStyle === "auth_token") {
parsed["ANTHROPIC_AUTH_TOKEN"] = "";
} else {
parsed["ANTHROPIC_API_KEY"] = "";
}
finalExtraEnv = JSON.stringify(parsed);
} catch {
// If parse fails, fall back to simple replacement
finalExtraEnv = authStyle === "auth_token"
? '{"ANTHROPIC_AUTH_TOKEN":""}'
: '{"ANTHROPIC_API_KEY":""}';
}
}
// In edit mode, preserve existing role_models_json unless the user modifies mapping fields
let roleModelsJson = (isEdit && editProvider?.role_models_json) ? editProvider.role_models_json : "{}";
// Model mapping (sonnet/opus/haiku → actual API model IDs)
// Merge into existing roleModels to preserve roles not shown in this preset.
// If the preset exposes these fields and user cleared them all, remove those keys.
if (preset.fields.includes("model_mapping")) {
const hasAny = mapSonnet.trim() || mapOpus.trim() || mapHaiku.trim();
if (hasAny) {
// If user fills any, all 3 are required
if (!mapSonnet.trim() || !mapOpus.trim() || !mapHaiku.trim()) {
setError(isZh
? '模型映射需要同时填写 Sonnet、Opus、Haiku 三个模型名称'
: 'Model mapping requires all 3 model names (Sonnet, Opus, Haiku)');
return;
}
const existing = (() => { try { return JSON.parse(roleModelsJson); } catch { return {}; } })();
roleModelsJson = JSON.stringify({
...existing,
sonnet: mapSonnet.trim(),
opus: mapOpus.trim(),
haiku: mapHaiku.trim(),
});
} else {
// All cleared — remove these keys from existing
const existing = (() => { try { return JSON.parse(roleModelsJson); } catch { return {}; } })();
delete existing.sonnet;
delete existing.opus;
delete existing.haiku;
roleModelsJson = JSON.stringify(existing);
}
}
// Inject model name into role_models_json — merge, don't replace.
// If the preset exposes model_names and user cleared it, remove the default key.
if (preset.fields.includes("model_names")) {
const existing = (() => { try { return JSON.parse(roleModelsJson); } catch { return {}; } })();
if (modelName.trim()) {
roleModelsJson = JSON.stringify({ ...existing, default: modelName.trim() });
} else {
delete existing.default;
roleModelsJson = JSON.stringify(existing);
}
}
// Validate JSON fields
for (const [label, val] of [
["Extra environment variables", finalExtraEnv],
...(isEdit ? [["Headers", headersJson]] : []),
] as const) {
if (val && val.trim()) {
try { JSON.parse(val); } catch {
setError(`${label} must be valid JSON`);
return;
}
}
}
setSaving(true);
try {
await onSave({
name: name.trim() || preset.name,
provider_type: preset.provider_type,
protocol: preset.protocol,
base_url: baseUrl.trim(),
api_key: apiKey,
extra_env: finalExtraEnv,
role_models_json: roleModelsJson,
headers_json: isEdit ? headersJson.trim() || "{}" : undefined,
env_overrides_json: isEdit ? envOverridesJson.trim() || "" : undefined,
notes: isEdit ? notes.trim() : "",
});
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : (isEdit ? "Failed to update provider" : "Failed to add provider"));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[28rem]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2.5">
{preset.icon}
{isEdit ? t('provider.editProvider') : t('provider.connect')} {preset.name}
</DialogTitle>
<DialogDescription>
{isZh ? preset.descriptionZh : preset.description}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 min-w-0">
{/* Name field — custom/thirdparty */}
{preset.fields.includes("name") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={preset.name}
className="text-sm"
/>
</div>
)}
{/* Base URL */}
{preset.fields.includes("base_url") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.baseUrl')}</Label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
className="text-sm font-mono"
/>
</div>
)}
{/* API Key with optional auth style select */}
{preset.fields.includes("api_key") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{preset.key === "anthropic-thirdparty"
? (authStyle === "auth_token" ? "Auth Token" : "API Key")
: "API Key"}
</Label>
<div className="flex gap-2">
{preset.key === "anthropic-thirdparty" && (
<Select
value={authStyle}
onValueChange={(v) => {
const newStyle = v as "api_key" | "auth_token";
setAuthStyle(newStyle);
if (isEdit && editProvider?.api_key) {
if (newStyle !== initialAuthStyle) {
// Switching away — clear masked key to force re-entry
setApiKey("");
} else {
// Switching back to original — restore masked key
setApiKey(editProvider.api_key);
}
}
}}
>
<SelectTrigger className="w-[130px] shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api_key">API Key</SelectItem>
<SelectItem value="auth_token">Auth Token</SelectItem>
</SelectContent>
</Select>
)}
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={authStyle === "auth_token" ? "token-..." : "sk-..."}
className="text-sm font-mono flex-1"
autoFocus
/>
</div>
</div>
)}
{/* Model name — for providers that need user-specified model */}
{preset.fields.includes("model_names") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.modelName' as TranslationKey)}</Label>
<Input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ark-code-latest"
className="text-sm font-mono"
/>
<p className="text-[11px] text-muted-foreground">
{isZh
? '在服务商控制台配置的模型名称,如 ark-code-latest、doubao-seed-2.0-code'
: 'Model name configured in provider console, e.g. ark-code-latest'}
</p>
</div>
)}
{/* Extra env — bedrock/vertex/custom always shown */}
{preset.fields.includes("extra_env") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
<Textarea
value={extraEnv}
onChange={(e) => setExtraEnv(e.target.value)}
className="text-sm font-mono min-h-[80px]"
rows={3}
/>
</div>
)}
{/* Advanced options — for presets that don't normally show extra_env */}
{!preset.fields.includes("extra_env") && (
<>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <CaretUp size={12} /> : <CaretDown size={12} />}
{t('provider.advancedOptions')}
</button>
{showAdvanced && (
<div className="space-y-4 border-t border-border/50 pt-3">
{/* Model mapping (sonnet/opus/haiku → API model IDs) */}
{preset.fields.includes("model_mapping") && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{isZh ? '模型名称映射' : 'Model Name Mapping'}
</Label>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{isZh
? '如果服务商使用不同的模型名称(如 claude-sonnet-4-6),在此映射。留空则使用默认名称(sonnet / opus / haiku)。'
: 'Map model names if the provider uses different IDs (e.g. claude-sonnet-4-6). Leave empty to use defaults (sonnet / opus / haiku).'}
</p>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 items-center">
<span className="text-xs text-muted-foreground text-right">Sonnet</span>
<Input
value={mapSonnet}
onChange={(e) => setMapSonnet(e.target.value)}
placeholder="claude-sonnet-4-6"
className="text-sm font-mono h-8"
/>
<span className="text-xs text-muted-foreground text-right">Opus</span>
<Input
value={mapOpus}
onChange={(e) => setMapOpus(e.target.value)}
placeholder="claude-opus-4-6"
className="text-sm font-mono h-8"
/>
<span className="text-xs text-muted-foreground text-right">Haiku</span>
<Input
value={mapHaiku}
onChange={(e) => setMapHaiku(e.target.value)}
placeholder="claude-haiku-4-5-20251001"
className="text-sm font-mono h-8"
/>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
<Textarea
value={extraEnv}
onChange={(e) => setExtraEnv(e.target.value)}
className="text-sm font-mono min-h-[60px]"
rows={3}
/>
</div>
{/* Edit-mode only: headers, env overrides, notes */}
{isEdit && (
<>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Headers (JSON)</Label>
<Textarea
value={headersJson}
onChange={(e) => setHeadersJson(e.target.value)}
placeholder='{"X-Custom-Header": "value"}'
className="text-sm font-mono min-h-[60px]"
rows={2}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Env Overrides (JSON)</Label>
<Textarea
value={envOverridesJson}
onChange={(e) => setEnvOverridesJson(e.target.value)}
placeholder='{"CLAUDE_CODE_USE_BEDROCK": "1"}'
className="text-sm font-mono min-h-[60px]"
rows={2}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('provider.notes')}</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('provider.notesPlaceholder')}
className="text-sm"
rows={2}
/>
</div>
</>
)}
</div>
)}
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={saving} className="gap-2">
{saving && <SpinnerGap size={16} className="animate-spin" />}
{saving ? t('provider.saving') : isEdit ? t('provider.update') : t('provider.connect')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Preset matcher — find which quick preset a provider was created from
// ---------------------------------------------------------------------------
function findMatchingPreset(provider: ApiProvider): QuickPreset | undefined {
// Exact base_url match (most specific)
if (provider.base_url) {
const match = QUICK_PRESETS.find(p => p.base_url && p.base_url === provider.base_url);
if (match) return match;
}
// Type-based fallback for known types
if (provider.provider_type === "bedrock") return QUICK_PRESETS.find(p => p.key === "bedrock");
if (provider.provider_type === "vertex") return QUICK_PRESETS.find(p => p.key === "vertex");
if (provider.provider_type === "openrouter") return QUICK_PRESETS.find(p => p.key === "openrouter");
if (provider.provider_type === "gemini-image") return QUICK_PRESETS.find(p => p.key === "gemini-image");
if (provider.provider_type === "anthropic" && provider.base_url === "https://api.anthropic.com") {
return QUICK_PRESETS.find(p => p.key === "anthropic-official");
}
// Anthropic-type with custom base_url → anthropic-thirdparty
if (provider.provider_type === "anthropic" && provider.base_url) {
return QUICK_PRESETS.find(p => p.key === "anthropic-thirdparty");
}
// Custom/OpenAI-compatible
if (provider.provider_type === "custom" || provider.protocol === "openai-compatible") {
return QUICK_PRESETS.find(p => p.key === "custom-openai");
}
return undefined;
}
// ---------------------------------------------------------------------------
// Main component
@@ -1002,7 +203,7 @@ export function ProviderManager() {
{t('provider.default')}
</Badge>
{Object.keys(envDetected).length > 0 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-green-600 dark:text-green-400 border-green-500/30">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-status-success-foreground border-status-success-border">
ENV
</Badge>
)}
@@ -1061,18 +262,19 @@ export function ProviderManager() {
{GEMINI_IMAGE_MODELS.map((m) => {
const isActive = getGeminiImageModel(provider) === m.value;
return (
<button
<Button
key={m.value}
type="button"
variant="ghost"
size="sm"
onClick={() => handleImageModelChange(provider, m.value)}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium border transition-all ${
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium border h-auto ${
isActive
? 'bg-primary/10 text-primary border-primary/30'
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
}`}
>
{m.label}
</button>
</Button>
);
})}
</div>
+6 -5
View File
@@ -1,9 +1,9 @@
"use client";
import { useState, useCallback, useSyncExternalStore } from "react";
import type { Icon } from "@phosphor-icons/react";
import { Gear, Code, UserCircle, Plug, ChartBar } from "@phosphor-icons/react";
import { type Icon, Gear, Code, UserCircle, Plug, ChartBar } from "@/components/ui/icon";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { GeneralSection } from "./GeneralSection";
import { ProviderManager } from "./ProviderManager";
import { CliSettingsSection } from "./CliSettingsSection";
@@ -81,11 +81,12 @@ export function SettingsLayout() {
{/* Sidebar */}
<nav className="flex w-52 shrink-0 flex-col gap-1 border-r border-border/50 p-3">
{sidebarItems.map((item) => (
<button
<Button
key={item.id}
variant="ghost"
onClick={() => handleSectionChange(item.id)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left",
"justify-start gap-3 px-3 py-2 text-sm font-medium text-left w-full",
activeSection === item.id
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
@@ -93,7 +94,7 @@ export function SettingsLayout() {
>
<item.icon size={16} className="shrink-0" />
{t(settingsLabelKeys[item.label])}
</button>
</Button>
))}
</nav>
@@ -12,6 +12,7 @@ import {
Legend,
ResponsiveContainer,
} from "recharts";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/hooks/useTranslation";
// ---------------------------------------------------------------------------
@@ -235,17 +236,17 @@ export function UsageStatsSection() {
{/* Day range selector */}
<div className="flex items-center gap-2">
{RANGE_OPTIONS.map((opt) => (
<button
<Button
key={opt.days}
variant={days === opt.days ? "default" : "secondary"}
size="sm"
onClick={() => setDays(opt.days)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
days === opt.days
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
className={`px-3 py-1 text-xs font-medium ${
days !== opt.days ? "text-muted-foreground hover:bg-accent" : ""
}`}
>
{opt.label}
</button>
</Button>
))}
</div>
@@ -290,7 +291,7 @@ export function UsageStatsSection() {
)}
{error && (
<div className="flex h-64 items-center justify-center text-sm text-red-500">
<div className="flex h-64 items-center justify-center text-sm text-status-error-foreground">
{error}
</div>
)}
@@ -0,0 +1,199 @@
"use client";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SpinnerGap } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import type { WorkspaceInspectResult } from "@/types";
export type ConfirmDialogType =
| { kind: 'not_found' }
| { kind: 'empty' }
| { kind: 'normal_directory' }
| { kind: 'existing_workspace'; summary: NonNullable<WorkspaceInspectResult['summary']> }
| { kind: 'partial_workspace' };
interface WorkspaceConfirmDialogsProps {
confirmDialog: ConfirmDialogType | null;
initializing: boolean;
onClose: () => void;
onExecuteSave: (initialize: boolean, resetOnboarding?: boolean, navigateMode?: 'new' | 'reuse') => void;
}
export function WorkspaceConfirmDialogs({
confirmDialog,
initializing,
onClose,
onExecuteSave,
}: WorkspaceConfirmDialogsProps) {
const { t } = useTranslation();
const handleOpenChange = (open: boolean) => {
if (!open) onClose();
};
return (
<>
{/* Non-existent path — offer to create */}
<AlertDialog open={confirmDialog?.kind === 'not_found'} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmNotFoundTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('assistant.confirmNotFoundDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => onExecuteSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmCreate')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Empty directory confirmation */}
<AlertDialog open={confirmDialog?.kind === 'empty'} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmEmptyTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('assistant.confirmEmptyDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => onExecuteSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmInitialize')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Normal directory confirmation */}
<AlertDialog open={confirmDialog?.kind === 'normal_directory'} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmNormalTitle')}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>{t('assistant.confirmNormalDesc')}</p>
<p className="text-xs text-muted-foreground">{t('assistant.confirmNormalHint')}</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => onExecuteSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmInitialize')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Existing workspace confirmation */}
<AlertDialog open={confirmDialog?.kind === 'existing_workspace'} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmExistingTitle')}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>{t('assistant.confirmExistingDesc')}</p>
{confirmDialog?.kind === 'existing_workspace' && (
<div className="rounded border border-border/50 p-3 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('assistant.summaryOnboarding')}:</span>
<span>{confirmDialog.summary.onboardingComplete
? t('assistant.onboardingComplete')
: t('assistant.onboardingNotStarted')
}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('assistant.summaryLastCheckIn')}:</span>
<span>{confirmDialog.summary.lastCheckInDate || t('assistant.summaryNever')}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('assistant.summaryFileCount')}:</span>
<span>{confirmDialog.summary.fileCount}</span>
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-col gap-2">
<Button size="sm" onClick={() => onExecuteSave(false, false, 'reuse')} disabled={initializing}>
{t('assistant.takeoverContinue')}
</Button>
<Button size="sm" variant="outline" onClick={() => onExecuteSave(false, true, 'new')} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.takeoverReonboard')
)}
</Button>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Partial workspace confirmation */}
<AlertDialog open={confirmDialog?.kind === 'partial_workspace'} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('assistant.confirmPartialTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('assistant.confirmPartialDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => onExecuteSave(true)} disabled={initializing}>
{initializing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.initializing')}
</>
) : (
t('assistant.confirmRepair')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,95 @@
"use client";
import { Button } from "@/components/ui/button";
import { SpinnerGap } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
// ── Onboarding Status Card ──
interface OnboardingCardProps {
onboardingComplete: boolean;
creatingSession: boolean;
onStartOnboarding: () => void;
}
export function OnboardingCard({ onboardingComplete, creatingSession, onStartOnboarding }: OnboardingCardProps) {
const { t } = useTranslation();
return (
<div className="rounded-lg border border-border/50 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('assistant.onboardingTitle')}</h2>
<p className="text-xs text-muted-foreground mt-1">{t('assistant.onboardingDesc')}</p>
<p className="text-xs mt-1">
{onboardingComplete
? <span className="text-status-success-foreground">{t('assistant.onboardingComplete')}</span>
: <span className="text-status-warning-foreground">{t('assistant.onboardingNotStarted')}</span>
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={onStartOnboarding}
disabled={creatingSession}
>
{creatingSession ? (
<SpinnerGap size={14} className="animate-spin" />
) : onboardingComplete
? t('assistant.redoOnboarding')
: t('assistant.startOnboarding')
}
</Button>
</div>
</div>
);
}
// ── Daily Check-in Card ──
interface CheckInCardProps {
lastCheckInDate: string | null;
checkInDoneToday: boolean;
creatingSession: boolean;
onStartCheckIn: () => void;
}
export function CheckInCard({ lastCheckInDate, checkInDoneToday, creatingSession, onStartCheckIn }: CheckInCardProps) {
const { t } = useTranslation();
return (
<div className="rounded-lg border border-border/50 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('assistant.checkInTitle')}</h2>
<p className="text-xs text-muted-foreground mt-1">{t('assistant.checkInDesc')}</p>
<p className="text-xs mt-1">
{lastCheckInDate && (
<span className="text-muted-foreground">
{t('assistant.lastCheckIn')}: {lastCheckInDate}
</span>
)}
{" "}
{checkInDoneToday
? <span className="text-status-success-foreground">{t('assistant.checkInToday')}</span>
: <span className="text-status-warning-foreground">{t('assistant.checkInNeeded')}</span>
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={onStartCheckIn}
disabled={creatingSession}
>
{creatingSession ? (
<SpinnerGap size={14} className="animate-spin" />
) : (
t('assistant.startCheckIn')
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,200 @@
"use client";
import { Button } from "@/components/ui/button";
import { SpinnerGap } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import type { FileStatus, TaxonomyCategoryInfo, IndexStats } from "./workspace-types";
const FILE_LABELS: Record<string, string> = {
claude: "claude.md",
soul: "soul.md",
user: "user.md",
memory: "memory.md",
};
// ── Files Tab ──
interface FilesTabPanelProps {
files: Record<string, FileStatus>;
refreshingDocs: boolean;
onRefreshDocs: () => void;
}
export function FilesTabPanel({ files, refreshingDocs, onRefreshDocs }: FilesTabPanelProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
{Object.entries(FILE_LABELS).map(([key, label]) => {
const file = files[key];
return (
<div key={key} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs">{label}</span>
<div className="flex items-center gap-2">
{file?.exists ? (
<>
<span className="text-xs text-muted-foreground">
{t('assistant.fileChars', { count: String(file.chars) })}
</span>
<span className="h-2 w-2 rounded-full bg-status-success" />
<span className="text-xs text-status-success-foreground">{t('assistant.fileExists')}</span>
</>
) : (
<>
<span className="h-2 w-2 rounded-full bg-status-warning" />
<span className="text-xs text-status-warning-foreground">{t('assistant.fileMissing')}</span>
</>
)}
</div>
</div>
);
})}
<div className="flex items-center justify-end mt-3">
<Button
variant="outline"
size="sm"
onClick={onRefreshDocs}
disabled={refreshingDocs}
>
{refreshingDocs ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.refreshingDocs')}
</>
) : (
t('assistant.refreshDocs')
)}
</Button>
</div>
</div>
);
}
// ── Taxonomy Tab ──
interface TaxonomyTabPanelProps {
taxonomy: TaxonomyCategoryInfo[];
}
export function TaxonomyTabPanel({ taxonomy }: TaxonomyTabPanelProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('assistant.taxonomyDesc')}</p>
{taxonomy.length === 0 ? (
<p className="text-xs text-muted-foreground italic">{t('assistant.taxonomyEmpty')}</p>
) : (
<div className="space-y-1.5">
{taxonomy.map(cat => (
<div key={cat.id} className="flex items-center justify-between text-xs border border-border/30 rounded px-2 py-1.5">
<div>
<span className="font-medium">{cat.label}</span>
<span className="text-muted-foreground ml-2">{t('assistant.taxonomyRole')}: {cat.role}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{t('assistant.taxonomySource')}: {cat.source}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] ${
cat.confidence > 0.7 ? 'bg-status-success-muted text-status-success-foreground' :
cat.confidence > 0.4 ? 'bg-status-warning-muted text-status-warning-foreground' :
'bg-status-error-muted text-status-error-foreground'
}`}>
{Math.round(cat.confidence * 100)}%
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ── Index Tab ──
interface IndexTabPanelProps {
indexStats: IndexStats | null;
reindexing: boolean;
onReindex: () => void;
}
export function IndexTabPanel({ indexStats, reindexing, onReindex }: IndexTabPanelProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('assistant.indexDesc')}</p>
{indexStats ? (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">{t('assistant.indexFiles', { count: String(indexStats.fileCount) })}</span>
</div>
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">{t('assistant.indexChunks', { count: String(indexStats.chunkCount) })}</span>
</div>
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">{t('assistant.indexStale', { count: String(indexStats.staleCount) })}</span>
</div>
<div className="border border-border/30 rounded px-2 py-1.5">
<span className="text-muted-foreground">
{t('assistant.indexLastIndexed')}: {indexStats.lastIndexed ? new Date(indexStats.lastIndexed).toLocaleString() : 'never'}
</span>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground italic">{t('common.loading')}</p>
)}
<div className="flex items-center justify-end mt-2">
<Button
variant="outline"
size="sm"
onClick={onReindex}
disabled={reindexing}
>
{reindexing ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.indexReindexing')}
</>
) : (
t('assistant.indexReindex')
)}
</Button>
</div>
</div>
);
}
// ── Organize Tab ──
interface OrganizeTabPanelProps {
archiving: boolean;
onArchive: () => void;
}
export function OrganizeTabPanel({ archiving, onArchive }: OrganizeTabPanelProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('assistant.organizeDesc')}</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onArchive}
disabled={archiving}
>
{archiving ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1" />
{t('assistant.organizeArchiving')}
</>
) : (
t('assistant.organizeArchive')
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,316 @@
"use client";
import type { ReactNode } from "react";
import { HardDrives, Gear } from "@/components/ui/icon";
import type { ApiProvider } from "@/types";
import Anthropic from "@lobehub/icons/es/Anthropic";
import OpenRouter from "@lobehub/icons/es/OpenRouter";
import Zhipu from "@lobehub/icons/es/Zhipu";
import Kimi from "@lobehub/icons/es/Kimi";
import Moonshot from "@lobehub/icons/es/Moonshot";
import Minimax from "@lobehub/icons/es/Minimax";
import Aws from "@lobehub/icons/es/Aws";
import Bedrock from "@lobehub/icons/es/Bedrock";
import Google from "@lobehub/icons/es/Google";
import Volcengine from "@lobehub/icons/es/Volcengine";
import Bailian from "@lobehub/icons/es/Bailian";
// ---------------------------------------------------------------------------
// Brand icon resolver
// ---------------------------------------------------------------------------
/** Map a provider name / base_url to a brand icon */
export function getProviderIcon(name: string, baseUrl: string): ReactNode {
const lower = name.toLowerCase();
const url = baseUrl.toLowerCase();
if (lower.includes("openrouter")) return <OpenRouter size={18} />;
if (url.includes("bigmodel.cn") || url.includes("z.ai") || lower.includes("glm") || lower.includes("zhipu") || lower.includes("chatglm"))
return <Zhipu size={18} />;
if (url.includes("kimi.com") || lower.includes("kimi")) return <Kimi size={18} />;
if (url.includes("moonshot") || lower.includes("moonshot")) return <Moonshot size={18} />;
if (url.includes("minimax") || lower.includes("minimax")) return <Minimax size={18} />;
if (url.includes("volces.com") || url.includes("volcengine") || lower.includes("volcengine") || lower.includes("火山") || lower.includes("doubao") || lower.includes("豆包"))
return <Volcengine size={18} />;
if (url.includes("dashscope") || lower.includes("bailian") || lower.includes("百炼") || lower.includes("aliyun"))
return <Bailian size={18} />;
if (lower.includes("bedrock")) return <Bedrock size={18} />;
if (lower.includes("vertex") || lower.includes("google")) return <Google size={18} />;
if (lower.includes("aws")) return <Aws size={18} />;
if (lower.includes("anthropic") || url.includes("anthropic")) return <Anthropic size={18} />;
return <HardDrives size={18} className="text-muted-foreground" />;
}
// ---------------------------------------------------------------------------
// Quick-add preset definitions
// ---------------------------------------------------------------------------
export interface QuickPreset {
key: string; // unique key
name: string;
description: string;
descriptionZh: string;
icon: ReactNode;
// Pre-filled provider data
provider_type: string;
/** Wire protocol — determines how the provider is dispatched at runtime */
protocol: string;
base_url: string;
extra_env: string;
// Which fields user must fill
fields: ("name" | "api_key" | "base_url" | "extra_env" | "model_names" | "model_mapping")[];
// Category: 'chat' (default) or 'media'
category?: "chat" | "media";
}
export const QUICK_PRESETS: QuickPreset[] = [
// ── Custom endpoints ──
{
key: "custom-openai",
name: "Custom API (OpenAI-compatible)",
description: "OpenAI-compatible custom endpoint",
descriptionZh: "自定义 OpenAI 兼容 API 端点",
icon: <Gear size={18} className="text-muted-foreground" />,
provider_type: "custom",
protocol: "openai-compatible",
base_url: "",
extra_env: "{}",
fields: ["name", "api_key", "base_url", "extra_env"],
},
// ── Anthropic-compatible services ──
{
key: "anthropic-thirdparty",
name: "Anthropic Third-party API",
description: "Anthropic-compatible API — provide URL and Key",
descriptionZh: "Anthropic 兼容第三方 API — 填写地址和密钥",
icon: <Anthropic size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["name", "api_key", "base_url", "model_mapping"],
},
{
key: "anthropic-official",
name: "Anthropic",
description: "Official Anthropic API",
descriptionZh: "Anthropic 官方 API",
icon: <Anthropic size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.anthropic.com",
extra_env: "{}",
fields: ["api_key"],
},
{
key: "openrouter",
name: "OpenRouter",
description: "Use OpenRouter to access multiple models",
descriptionZh: "通过 OpenRouter 访问多种模型",
icon: <OpenRouter size={18} />,
provider_type: "openrouter",
protocol: "openrouter",
base_url: "https://openrouter.ai/api",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "glm-cn",
name: "GLM (CN)",
description: "Zhipu GLM Code Plan — China region",
descriptionZh: "智谱 GLM 编程套餐 — 中国区",
icon: <Zhipu size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://open.bigmodel.cn/api/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "glm-global",
name: "GLM (Global)",
description: "Zhipu GLM Code Plan — Global region",
descriptionZh: "智谱 GLM 编程套餐 — 国际区",
icon: <Zhipu size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.z.ai/api/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "kimi",
name: "Kimi Coding Plan",
description: "Kimi Coding Plan API",
descriptionZh: "Kimi 编程计划 API",
icon: <Kimi size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.kimi.com/coding/",
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
fields: ["api_key"],
},
{
key: "moonshot",
name: "Moonshot",
description: "Moonshot AI API",
descriptionZh: "月之暗面 API",
icon: <Moonshot size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.moonshot.cn/anthropic",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "minimax-cn",
name: "MiniMax (CN)",
description: "MiniMax Code Plan — China region",
descriptionZh: "MiniMax 编程套餐 — 中国区",
icon: <Minimax size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.minimaxi.com/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "minimax-global",
name: "MiniMax (Global)",
description: "MiniMax Code Plan — Global region",
descriptionZh: "MiniMax 编程套餐 — 国际区",
icon: <Minimax size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://api.minimax.io/anthropic",
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
{
key: "volcengine",
name: "Volcengine Ark",
description: "Volcengine Ark Coding Plan — Doubao, GLM, DeepSeek, Kimi",
descriptionZh: "字节火山方舟 Coding Plan — 豆包、GLM、DeepSeek、Kimi",
icon: <Volcengine size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://ark.cn-beijing.volces.com/api/coding",
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
fields: ["api_key", "model_names"],
},
{
key: "bailian",
name: "Aliyun Bailian",
description: "Aliyun Bailian Coding Plan — Qwen, GLM, Kimi, MiniMax",
descriptionZh: "阿里云百炼 Coding Plan — 通义千问、GLM、Kimi、MiniMax",
icon: <Bailian size={18} />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
extra_env: '{"ANTHROPIC_API_KEY":""}',
fields: ["api_key"],
},
// ── Cloud platform providers ──
{
key: "bedrock",
name: "AWS Bedrock",
description: "Amazon Bedrock — requires AWS credentials",
descriptionZh: "Amazon Bedrock — 需要 AWS 凭证",
icon: <Bedrock size={18} />,
provider_type: "bedrock",
protocol: "bedrock",
base_url: "",
extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}',
fields: ["extra_env"],
},
{
key: "vertex",
name: "Google Vertex",
description: "Google Vertex AI — requires GCP credentials",
descriptionZh: "Google Vertex AI — 需要 GCP 凭证",
icon: <Google size={18} />,
provider_type: "vertex",
protocol: "vertex",
base_url: "",
extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}',
fields: ["extra_env"],
},
// ── Proxy / gateway ──
{
key: "litellm",
name: "LiteLLM",
description: "LiteLLM proxy — local or remote",
descriptionZh: "LiteLLM 代理 — 本地或远程",
icon: <HardDrives size={18} className="text-muted-foreground" />,
provider_type: "anthropic",
protocol: "anthropic",
base_url: "http://localhost:4000",
extra_env: "{}",
fields: ["api_key", "base_url"],
},
// ── Media providers ──
{
key: "gemini-image",
name: "Google Gemini (Image)",
description: "Nano Banana Pro — AI image generation by Google Gemini",
descriptionZh: "Nano Banana Pro — Google Gemini AI 图片生成",
icon: <Google size={18} />,
provider_type: "gemini-image",
protocol: "gemini-image",
base_url: "https://generativelanguage.googleapis.com/v1beta",
extra_env: '{"GEMINI_API_KEY":""}',
fields: ["api_key"],
category: "media",
},
];
// ---------------------------------------------------------------------------
// Gemini image model definitions
// ---------------------------------------------------------------------------
export const GEMINI_IMAGE_MODELS = [
{ value: 'gemini-3.1-flash-image-preview', label: 'Nano Banana 2' },
{ value: 'gemini-3-pro-image-preview', label: 'Nano Banana Pro' },
{ value: 'gemini-2.5-flash-image', label: 'Nano Banana' },
];
export const DEFAULT_GEMINI_IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
export function getGeminiImageModel(provider: ApiProvider): string {
try {
const env = JSON.parse(provider.extra_env || '{}');
return env.GEMINI_IMAGE_MODEL || DEFAULT_GEMINI_IMAGE_MODEL;
} catch {
return DEFAULT_GEMINI_IMAGE_MODEL;
}
}
// ---------------------------------------------------------------------------
// Preset matcher — find which quick preset a provider was created from
// ---------------------------------------------------------------------------
export function findMatchingPreset(provider: ApiProvider): QuickPreset | undefined {
// Exact base_url match (most specific)
if (provider.base_url) {
const match = QUICK_PRESETS.find(p => p.base_url && p.base_url === provider.base_url);
if (match) return match;
}
// Type-based fallback for known types
if (provider.provider_type === "bedrock") return QUICK_PRESETS.find(p => p.key === "bedrock");
if (provider.provider_type === "vertex") return QUICK_PRESETS.find(p => p.key === "vertex");
if (provider.provider_type === "openrouter") return QUICK_PRESETS.find(p => p.key === "openrouter");
if (provider.provider_type === "gemini-image") return QUICK_PRESETS.find(p => p.key === "gemini-image");
if (provider.provider_type === "anthropic" && provider.base_url === "https://api.anthropic.com") {
return QUICK_PRESETS.find(p => p.key === "anthropic-official");
}
// Anthropic-type with custom base_url → anthropic-thirdparty
if (provider.provider_type === "anthropic" && provider.base_url) {
return QUICK_PRESETS.find(p => p.key === "anthropic-thirdparty");
}
// Custom/OpenAI-compatible
if (provider.provider_type === "custom" || provider.protocol === "openai-compatible") {
return QUICK_PRESETS.find(p => p.key === "custom-openai");
}
return undefined;
}
@@ -0,0 +1,40 @@
export interface FileStatus {
exists: boolean;
chars: number;
preview: string;
}
export interface WorkspaceState {
onboardingComplete: boolean;
lastCheckInDate: string | null;
schemaVersion: number;
}
export interface TaxonomyCategoryInfo {
id: string;
label: string;
role: string;
confidence: number;
source: string;
paths: string[];
}
export interface IndexStats {
fileCount: number;
chunkCount: number;
lastIndexed: number;
staleCount: number;
}
export interface WorkspaceInfo {
path: string | null;
valid?: boolean;
reason?: string;
exists?: boolean;
files: Record<string, FileStatus>;
state: WorkspaceState | null;
}
export type TabId = 'files' | 'taxonomy' | 'index' | 'organize';
export type PathValidationStatus = 'idle' | 'checking' | 'valid' | 'invalid';
+14 -14
View File
@@ -12,7 +12,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SpinnerGap, Globe, FolderOpen } from "@phosphor-icons/react";
import { SpinnerGap, Globe, FolderOpen } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { cn } from "@/lib/utils";
@@ -127,11 +127,11 @@ export function CreateSkillDialog({
<div className="space-y-2">
<Label>{t('skills.scope')}</Label>
<div className="flex gap-2">
<button
type="button"
<Button
variant="outline"
onClick={() => setScope("project")}
className={cn(
"flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
"flex-1 justify-start",
scope === "project"
? "border-primary/50 bg-primary/10 text-primary"
: "border-border hover:bg-accent"
@@ -139,20 +139,20 @@ export function CreateSkillDialog({
>
<FolderOpen size={16} />
{t('skills.project')}
</button>
<button
type="button"
</Button>
<Button
variant="outline"
onClick={() => setScope("global")}
className={cn(
"flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
"flex-1 justify-start",
scope === "global"
? "border-green-500/50 bg-green-500/10 text-green-600 dark:text-green-400"
? "border-status-success-border bg-status-success-muted text-status-success-foreground"
: "border-border hover:bg-accent"
)}
>
<Globe size={16} />
{t('skills.global')}
</button>
</Button>
</div>
<p className="text-xs text-muted-foreground">
{scope === "project"
@@ -166,19 +166,19 @@ export function CreateSkillDialog({
<Label>{t('skills.template')}</Label>
<div className="flex gap-2 flex-wrap">
{TEMPLATES.map((tpl, i) => (
<button
<Button
key={tpl.label}
type="button"
variant="outline"
size="xs"
onClick={() => setTemplateIdx(i)}
className={cn(
"rounded-md border px-3 py-1 text-xs transition-colors",
templateIdx === i
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-accent"
)}
>
{tpl.label}
</button>
</Button>
))}
</div>
</div>
@@ -9,7 +9,7 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { SpinnerGap, CheckCircle, X } from "@phosphor-icons/react";
import { SpinnerGap, CheckCircle, X } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
interface InstallProgressDialogProps {
@@ -142,10 +142,10 @@ export function InstallProgressDialog({
<SpinnerGap size={20} className="animate-spin text-primary" />
)}
{phase === "success" && (
<CheckCircle size={20} className="text-green-500" />
<CheckCircle size={20} className="text-status-success-foreground" />
)}
{phase === "error" && (
<X size={20} className="text-red-500" />
<X size={20} className="text-status-error-foreground" />
)}
{phase === "running"
? t('skills.installing')
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Input } from "@/components/ui/input";
import { MagnifyingGlass, SpinnerGap, Storefront } from "@phosphor-icons/react";
import { MagnifyingGlass, SpinnerGap, Storefront } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { MarketplaceSkillCard } from "./MarketplaceSkillCard";
import { MarketplaceSkillDetail } from "./MarketplaceSkillDetail";
@@ -92,7 +92,7 @@ export function MarketplaceBrowser({ onInstalled }: MarketplaceBrowserProps) {
)}
{error && (
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground px-3">
<p className="text-xs text-center text-red-500">{t('skills.marketplaceError')}</p>
<p className="text-xs text-center text-status-error-foreground">{t('skills.marketplaceError')}</p>
<p className="text-[10px] text-center">{error}</p>
</div>
)}
@@ -1,6 +1,6 @@
"use client";
import { Lightning, DownloadSimple, CheckCircle } from "@phosphor-icons/react";
import { Lightning, DownloadSimple, CheckCircle } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
@@ -36,7 +36,7 @@ export function MarketplaceSkillCard({
{skill.isInstalled ? (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 border-green-500/40 text-green-600 dark:text-green-400"
className="text-[10px] px-1.5 py-0 border-status-success-border text-status-success-foreground"
>
<CheckCircle size={10} className="mr-0.5" />
{t('skills.installed')}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more