From 7dcc0a1c0571ad9225a35e971cde307998b13386 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 1 Feb 2026 22:04:26 +0800 Subject: [PATCH] feat: update usage recommendations across multiple workflow commands to require user confirmation and improve clarity --- .claude/commands/ccw-debug.md | 29 +- .../commands/workflow/analyze-with-file.md | 20 +- .../commands/workflow/brainstorm-with-file.md | 16 +- .claude/commands/workflow/debug-with-file.md | 6 +- .claude/docs/HOOKS_ANALYSIS_REPORT.md | 453 ++++++++++++++ .claude/docs/HOOKS_DOCUMENTATION_INDEX.md | 224 +++++++ .claude/docs/HOOKS_OFFICIAL_GUIDE.md | 124 ++++ .claude/docs/HOOKS_OFFICIAL_REFERENCE.md | 268 +++++++++ .claude/docs/HOOKS_QUICK_REFERENCE.md | 390 ++++++++++++ .../examples/hooks_bash_command_validator.py | 85 +++ .../commands/CommandGroupAccordion.tsx | 401 +++++++++++++ .../components/commands/LocationSwitcher.tsx | 78 +++ ccw/frontend/src/components/commands/index.ts | 9 + ccw/frontend/src/components/hook/HookCard.tsx | 6 +- .../src/components/hook/HookFormDialog.tsx | 1 + .../components/hook/HookQuickTemplates.tsx | 91 +-- .../context/DependenciesCard.tsx | 2 +- .../components/ExecutionTab.tsx | 40 +- .../CliStreamMonitor/components/JsonCard.tsx | 178 +++--- .../CliStreamMonitor/components/JsonField.tsx | 73 ++- .../components/OutputLine.tsx | 75 +-- .../shared/CliStreamMonitor/index.ts | 7 +- .../messages/AssistantMessage.tsx | 66 +-- .../messages/ErrorMessage.tsx | 28 +- .../messages/SystemMessage.tsx | 28 +- .../CliStreamMonitor/messages/UserMessage.tsx | 47 +- .../shared/CliStreamMonitorLegacy.tsx | 88 ++- .../src/components/shared/Flowchart.tsx | 148 ++++- .../src/components/shared/SessionCard.tsx | 50 +- .../src/components/shared/TaskDrawer.tsx | 190 ++++-- ccw/frontend/src/components/ui/Badge.tsx | 13 +- ccw/frontend/src/components/ui/index.ts | 15 + .../workspace/WorkspaceSelector.tsx | 119 ++-- ccw/frontend/src/hooks/index.ts | 2 + ccw/frontend/src/hooks/useCommands.ts | 76 ++- ccw/frontend/src/hooks/useSessionDetail.ts | 6 +- ccw/frontend/src/hooks/useSkills.ts | 26 +- ccw/frontend/src/index.css | 24 +- ccw/frontend/src/lib/api.ts | 160 ++++- ccw/frontend/src/lib/theme.ts | 2 +- ccw/frontend/src/locales/en/cli-hooks.json | 29 +- ccw/frontend/src/locales/en/commands.json | 24 +- ccw/frontend/src/locales/en/lite-tasks.json | 37 ++ .../src/locales/en/session-detail.json | 27 + ccw/frontend/src/locales/en/sessions.json | 4 +- ccw/frontend/src/locales/en/skills.json | 15 +- ccw/frontend/src/locales/en/workspace.json | 4 +- ccw/frontend/src/locales/zh/cli-hooks.json | 29 +- ccw/frontend/src/locales/zh/commands.json | 24 +- ccw/frontend/src/locales/zh/lite-tasks.json | 37 ++ .../src/locales/zh/session-detail.json | 29 + ccw/frontend/src/locales/zh/sessions.json | 4 +- ccw/frontend/src/locales/zh/skills.json | 15 +- ccw/frontend/src/locales/zh/workspace.json | 4 +- .../src/pages/CommandsManagerPage.tsx | 391 +++++------- ccw/frontend/src/pages/FixSessionPage.tsx | 2 +- ccw/frontend/src/pages/HookManagerPage.tsx | 10 +- ccw/frontend/src/pages/LiteTaskDetailPage.tsx | 2 +- ccw/frontend/src/pages/LiteTasksPage.tsx | 558 ++++++++++++++++-- ccw/frontend/src/pages/SessionsPage.tsx | 108 +--- ccw/frontend/src/pages/SkillsManagerPage.tsx | 160 ++++- .../src/pages/session-detail/SummaryTab.tsx | 59 +- .../src/pages/session-detail/TaskListTab.tsx | 165 ++++-- ccw/src/cli.ts | 2 + ccw/src/commands/serve.ts | 4 +- ccw/src/commands/view.ts | 4 +- ccw/src/core/routes/hooks-routes.ts | 11 +- .../dashboard-js/components/hook-manager.js | 69 +++ ccw/src/templates/dashboard-js/i18n.js | 28 + .../dashboard-js/views/hook-manager.js | 9 +- 70 files changed, 4420 insertions(+), 1108 deletions(-) create mode 100644 .claude/docs/HOOKS_ANALYSIS_REPORT.md create mode 100644 .claude/docs/HOOKS_DOCUMENTATION_INDEX.md create mode 100644 .claude/docs/HOOKS_OFFICIAL_GUIDE.md create mode 100644 .claude/docs/HOOKS_OFFICIAL_REFERENCE.md create mode 100644 .claude/docs/HOOKS_QUICK_REFERENCE.md create mode 100644 .claude/examples/hooks_bash_command_validator.py create mode 100644 ccw/frontend/src/components/commands/CommandGroupAccordion.tsx create mode 100644 ccw/frontend/src/components/commands/LocationSwitcher.tsx create mode 100644 ccw/frontend/src/components/commands/index.ts diff --git a/.claude/commands/ccw-debug.md b/.claude/commands/ccw-debug.md index 241b9882..48372f13 100644 --- a/.claude/commands/ccw-debug.md +++ b/.claude/commands/ccw-debug.md @@ -837,15 +837,28 @@ Selected items call: `/issue:new "{issue summary} - {dimension}"` --- -## Usage Recommendations +## Usage Recommendations (Requires User Confirmation) -1. **First Time**: Use default mode (debug-first), observe workflow -2. **Quick Decision**: Use CLI Quick (--mode cli) for immediate recommendations -3. **Quick Fix**: Use `--hotfix --yes` for minimal diagnostics (debug mode) -4. **Learning**: Use debug-first, read `understanding.md` -5. **Complete Validation**: Use bidirectional for multi-dimensional insights -6. **Auto Repair**: Use test-first for automatic iteration -7. **Escalation**: Start with CLI Quick, escalate to other modes as needed +**Use `Skill(skill="ccw-debug", args="\"bug description\"")` when:** +- First time: Use default mode (debug-first), observe workflow + +**Use `Skill(skill="ccw-debug", args="--mode cli \"issue\"")` when:** +- Quick Decision: Immediate recommendations without full workflow + +**Use `Skill(skill="ccw-debug", args="--hotfix --yes \"issue\"")` when:** +- Quick Fix: Minimal diagnostics for production issues + +**Use `Skill(skill="ccw-debug", args="--mode debug \"issue\"")` when:** +- Learning: Read `understanding.md` for debugging insights + +**Use `Skill(skill="ccw-debug", args="--mode bidirectional \"issue\"")` when:** +- Complete Validation: Multi-dimensional insights from parallel workflows + +**Use `Skill(skill="ccw-debug", args="--mode test \"issue\"")` when:** +- Auto Repair: Automatic iteration with test-first approach + +**Use `Skill(skill="ccw-debug", args="--mode cli \"issue\"")` when:** +- Escalation: Start with CLI Quick, then escalate to other modes as needed --- diff --git a/.claude/commands/workflow/analyze-with-file.md b/.claude/commands/workflow/analyze-with-file.md index 68a3eb3a..2ac3292a 100644 --- a/.claude/commands/workflow/analyze-with-file.md +++ b/.claude/commands/workflow/analyze-with-file.md @@ -515,29 +515,35 @@ User agrees with current direction, wants deeper code analysis - Current design allows horizontal scaling without session affinity ``` -## Usage Recommendations +## Usage Recommendations (Requires User Confirmation) -**Use `/workflow:analyze-with-file` when:** +**Use `Skill(skill="workflow:analyze-with-file", args="\"topic\"")` when:** - Exploring a complex topic collaboratively - Need documented discussion trail - Decision-making requires multiple perspectives - Want to iterate on understanding with user input - Building shared understanding before implementation -**Use `/workflow:debug-with-file` when:** +**Use `Skill(skill="workflow:debug-with-file", args="\"bug description\"")` when:** - Diagnosing specific bugs - Need hypothesis-driven investigation - Focus on evidence and verification -**Use `/workflow:brainstorm-with-file` when:** +**Use `Skill(skill="workflow:brainstorm-with-file", args="\"topic or question\"")` when:** - Generating new ideas or solutions - Need creative exploration - Want divergent thinking before convergence -**Use `/workflow:lite-plan` when:** +**Use `Skill(skill="workflow:collaborative-plan-with-file", args="\"task description\"")` when:** +- Complex planning requiring multiple perspectives +- Large scope needing parallel sub-domain analysis +- Want shared collaborative planning document +- Need structured task breakdown with agent coordination + +**Use `Skill(skill="workflow:lite-plan", args="\"task description\"")` when:** - Ready to implement (past analysis phase) -- Need structured task breakdown -- Focus on execution planning +- Need simple task breakdown +- Focus on quick execution planning --- diff --git a/.claude/commands/workflow/brainstorm-with-file.md b/.claude/commands/workflow/brainstorm-with-file.md index 5f193058..0845d4e3 100644 --- a/.claude/commands/workflow/brainstorm-with-file.md +++ b/.claude/commands/workflow/brainstorm-with-file.md @@ -745,25 +745,31 @@ Dimensions matched against topic keywords to identify focus areas: See full markdown template in original file (lines 955-1161). -## Usage Recommendations +## Usage Recommendations (Requires User Confirmation) -**Use `/workflow:brainstorm-with-file` when:** +**Use `Skill(skill="workflow:brainstorm-with-file", args="\"topic or question\"")` when:** - Starting a new feature/product without clear direction - Facing a complex problem with multiple possible solutions - Need to explore alternatives before committing - Want documented thinking process for team review - Combining multiple stakeholder perspectives -**Use `/workflow:analyze-with-file` when:** +**Use `Skill(skill="workflow:analyze-with-file", args="\"topic\"")` when:** - Investigating existing code/system - Need factual analysis over ideation - Debugging or troubleshooting - Understanding current state -**Use `/workflow:plan` when:** +**Use `Skill(skill="workflow:collaborative-plan-with-file", args="\"task description\"")` when:** +- Complex planning requiring multiple perspectives +- Large scope needing parallel sub-domain analysis +- Want shared collaborative planning document +- Need structured task breakdown with agent coordination + +**Use `Skill(skill="workflow:lite-plan", args="\"task description\"")` when:** - Direction is already clear - Ready to move from ideas to execution -- Need implementation breakdown +- Need simple implementation breakdown --- diff --git a/.claude/commands/workflow/debug-with-file.md b/.claude/commands/workflow/debug-with-file.md index 320fb9b8..4602ce66 100644 --- a/.claude/commands/workflow/debug-with-file.md +++ b/.claude/commands/workflow/debug-with-file.md @@ -658,15 +658,15 @@ Why is config value None during update? | Hypothesis history | ❌ | ✅ hypotheses.json | | Gemini validation | ❌ | ✅ At key decision points | -## Usage Recommendations +## Usage Recommendations (Requires User Confirmation) -Use `/workflow:debug-with-file` when: +**Use `Skill(skill="workflow:debug-with-file", args="\"bug description\"")` when:** - Complex bugs requiring multiple investigation rounds - Learning from debugging process is valuable - Team needs to understand debugging rationale - Bug might recur, documentation helps prevention -Use `/workflow:debug` when: +**Use `Skill(skill="ccw-debug", args="--mode cli \"issue\"")` when:** - Simple, quick bugs - One-off issues - Documentation overhead not needed diff --git a/.claude/docs/HOOKS_ANALYSIS_REPORT.md b/.claude/docs/HOOKS_ANALYSIS_REPORT.md new file mode 100644 index 00000000..00f2d83b --- /dev/null +++ b/.claude/docs/HOOKS_ANALYSIS_REPORT.md @@ -0,0 +1,453 @@ +# Claude Code Hooks - 当前实现 vs 官方标准对比报告 + +## 执行摘要 + +当前 CCW 代码库中的钩子实现**不符合 Claude Code 官方标准**。存在以下主要问题: + +1. ❌ **钩子事件名称不符合官方标准** - 使用了错误的事件名称 +2. ❌ **配置结构不同** - 自定义了配置格式,不符合官方规范 +3. ❌ **使用了不存在的事件类型** - 某些事件在官方钩子系统中不存在 +4. ❌ **文档与实现不一致** - 代码中的注释引用的是自定义实现,而非官方标准 + +--- + +## 详细对比 + +### 1. 钩子事件名称对比 + +#### ❌ 当前实现(错误) + +```json +{ + "hooks": { + "session-start": [], // ❌ 错误:应为 SessionStart + "session-end": [], // ❌ 错误:应为 SessionEnd + "file-modified": [], // ❌ 错误:官方不支持此事件 + "context-request": [], // ❌ 错误:官方不支持此事件 + "PostToolUse": [] // ✅ 正确 + } +} +``` + +#### ✅ 官方标准(正确) + +```json +{ + "hooks": { + "SessionStart": [], // ✅ 当会话开始或恢复时触发 + "UserPromptSubmit": [], // ✅ 当用户提交提示词时触发 + "PreToolUse": [], // ✅ 工具调用前触发,可以阻止 + "PermissionRequest": [], // ✅ 权限对话出现时触发 + "PostToolUse": [], // ✅ 工具调用成功后触发 + "PostToolUseFailure": [], // ✅ 工具调用失败时触发 + "Notification": [], // ✅ 通知发送时触发 + "SubagentStart": [], // ✅ 子代理生成时触发 + "SubagentStop": [], // ✅ 子代理完成时触发 + "Stop": [], // ✅ Claude 完成响应时触发 + "PreCompact": [], // ✅ 上下文压缩前触发 + "SessionEnd": [] // ✅ 会话终止时触发 + } +} +``` + +### 2. 配置结构对比 + +#### ❌ 当前实现(自定义结构) + +```json +{ + "hooks": { + "session-start": [ + { + "name": "Progressive Disclosure", + "description": "Injects progressive disclosure index", + "enabled": true, + "handler": "internal:context", + "timeout": 5000, + "failMode": "silent" + } + ] + }, + "hookSettings": { + "globalTimeout": 60000, + "defaultFailMode": "silent", + "allowAsync": true, + "enableLogging": true + } +} +``` + +**问题:** +- 使用了非标准字段:`name`, `description`, `enabled`, `handler`, `failMode` +- 使用了自定义的 `hookSettings` 配置 +- 结构过度复杂化 + +#### ✅ 官方标准(简洁标准) + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "bash /path/to/script.sh", + "timeout": 600, + "async": false + } + ] + } + ] + } +} +``` + +**优点:** +- 简洁明了的三层结构:事件 → 匹配器 → 处理器 +- 标准的字段集:`type`, `command`, `timeout`, `async` +- 支持三种处理器类型:`command`, `prompt`, `agent` + +### 3. 官方支持的钩子事件及其触发时机 + +| 事件名称 | 触发时机 | 可阻止 | 匹配器支持 | +|---------|---------|--------|-----------| +| `SessionStart` | 会话开始或恢复 | ❌ | startup, resume, clear, compact | +| `UserPromptSubmit` | 用户提交提示词前 | ✅ | ❌ | +| `PreToolUse` | 工具调用前 | ✅ | 工具名称 | +| `PermissionRequest` | 权限对话出现时 | ✅ | 工具名称 | +| `PostToolUse` | 工具调用成功后 | ❌ | 工具名称 | +| `PostToolUseFailure` | 工具调用失败时 | ❌ | 工具名称 | +| `Notification` | 通知发送时 | ❌ | 通知类型 | +| `SubagentStart` | 子代理生成时 | ❌ | 代理类型 | +| `SubagentStop` | 子代理完成时 | ✅ | 代理类型 | +| `Stop` | Claude 完成响应时 | ✅ | ❌ | +| `PreCompact` | 上下文压缩前 | ❌ | manual, auto | +| `SessionEnd` | 会话终止时 | ❌ | 终止原因 | + +**当前实现不支持的事件:** +- ❌ `file-modified` - 官方系统中不存在 +- ❌ `context-request` - 官方系统中不存在 + +### 4. 处理器类型对比 + +#### ❌ 当前实现 + +仅支持一种:`handler: "internal:context"`(自定义内部处理器) + +#### ✅ 官方标准 + +支持三种标准类型: + +1. **Command hooks** (`type: "command"`) + ```json + { + "type": "command", + "command": "bash /path/to/script.sh", + "timeout": 600, + "async": false + } + ``` + +2. **Prompt hooks** (`type: "prompt"`) + ```json + { + "type": "prompt", + "prompt": "Evaluate if this is safe to execute: $ARGUMENTS", + "model": "haiku", + "timeout": 30 + } + ``` + +3. **Agent hooks** (`type: "agent"`) + ```json + { + "type": "agent", + "prompt": "Verify tests pass: $ARGUMENTS", + "model": "haiku", + "timeout": 60 + } + ``` + +### 5. 匹配器对比 + +#### ❌ 当前实现 + +没有明确的匹配器概念,而是使用: +- `handler: "internal:context"` - 内部处理 +- 没有工具级别的过滤 + +#### ✅ 官方标准 + +完整的匹配器系统: + +```json +{ + "PreToolUse": [ + { + "matcher": "Edit|Write", // 仅在 Edit 或 Write 工具时触发 + "hooks": [ ... ] + } + ] +} +``` + +**支持的匹配器:** +- **工具事件**:`Bash`, `Edit`, `Write`, `Read`, `Glob`, `Grep`, `Task`, `WebFetch`, `WebSearch` +- **MCP工具**:`mcp__memory__.*`, `mcp__.*__write.*` +- **会话事件**:`startup`, `resume`, `clear`, `compact` +- **通知类型**:`permission_prompt`, `idle_prompt`, `auth_success` +- **代理类型**:`Bash`, `Explore`, `Plan` + +### 6. 输入/输出机制对比 + +#### ❌ 当前实现 + +- 未定义标准的 stdin/stdout 通信协议 +- 使用了自定义的环境变量:`$SESSION_ID`, `$FILE_PATH`, `$PROJECT_PATH` + +#### ✅ 官方标准 + +**标准 JSON stdin 输入:** +```json +{ + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/current/dir", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test" + } +} +``` + +**标准 exit code 输出:** +- **exit 0**: 成功,解析 stdout 的 JSON 决策 +- **exit 2**: 阻止性错误,stderr 成为 Claude 的反馈 +- **其他码**: 非阻止性错误,stderr 显示在详细模式 + +**标准 JSON stdout 输出:** +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow|deny|ask", + "permissionDecisionReason": "explanation" + } +} +``` + +### 7. 文件位置对比 + +#### 当前实现 + +示例配置文件位置: +- `ccw/src/templates/hooks-config-example.json` + +#### ✅ 官方标准 + +标准配置位置(优先级顺序): +1. `~/.claude/settings.json` - 全局用户配置 +2. `.claude/settings.json` - 项目配置(可提交) +3. `.claude/settings.local.json` - 项目本地配置(gitignored) +4. 插件 `hooks/hooks.json` - 插件内部 +5. Skill/Agent frontmatter - 技能或代理 + +--- + +## 代码库中的具体问题位置 + +### 1. 错误的配置示例 + +**文件:** `ccw/src/templates/hooks-config-example.json` + +```json +{ + "hooks": { + "session-start": [ ... ], // ❌ 应为 SessionStart + "session-end": [ ... ], // ❌ 应为 SessionEnd + "file-modified": [ ... ], // ❌ 不存在的事件 + "context-request": [ ... ] // ❌ 不存在的事件 + } +} +``` + +**应改为:** + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "echo 'Session started'" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "prettier --write $FILE_PATH" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "clear", + "hooks": [ + { + "type": "command", + "command": "echo 'Session ended'" + } + ] + } + ] + } +} +``` + +### 2. 错误的命令注释 + +**文件:** `ccw/src/commands/hook.ts` + +当前代码引用了自定义的钩子处理逻辑,但不符合官方标准。 + +--- + +## 修复建议 + +### 优先级 1:关键修复 + +- [ ] 更新 `hooks-config-example.json` 使用官方事件名称 +- [ ] 更新配置结构以符合官方三层标准 +- [ ] 移除不支持的事件类型:`file-modified`, `context-request` +- [ ] 文档化官方支持的事件列表 + +### 优先级 2:功能对齐 + +- [ ] 实现官方的标准 JSON stdin/stdout 通信 +- [ ] 实现标准的 exit code 处理 +- [ ] 支持标准的匹配器系统 + +### 优先级 3:增强 + +- [ ] 添加对 `prompt` 和 `agent` 处理器类型的支持 +- [ ] 实现标准的异步钩子支持(`async: true`) +- [ ] 添加对环境变量持久化的支持(`CLAUDE_ENV_FILE`) + +--- + +## 官方示例 + +### 例1:格式化代码后自动执行 + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" + } + ] + } + ] + } +} +``` + +### 例2:阻止编辑受保护文件 + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" + } + ] + } + ] + } +} +``` + +### 例3:会话开始时重新注入上下文 + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "echo 'Use Bun, not npm. Run bun test before committing.'" + } + ] + } + ] + } +} +``` + +### 例4:基于条件的权限决策 + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "prompt", + "prompt": "Is this a safe command to run? $ARGUMENTS" + } + ] + } + ] + } +} +``` + +--- + +## 参考资源 + +- 官方指南:https://code.claude.com/docs/en/hooks-guide +- 官方参考:https://code.claude.com/docs/en/hooks +- 官方示例:https://github.com/anthropics/claude-code/tree/main/examples/hooks + +--- + +## 总结 + +当前 CCW 的钩子实现是基于自定义规范的,**完全不兼容** Claude Code 官方钩子系统。为了与官方标准对齐,需要进行彻底的重构,包括: + +1. ✅ 采用官方的事件名称(已在 `.claude/docs/` 文件中提供) +2. ✅ 采用官方的三层配置结构 +3. ✅ 实现官方的 JSON stdin/stdout 通信协议 +4. ✅ 移除不存在的自定义事件 +5. ✅ 支持官方的三种处理器类型 + +这样才能确保当用户将 CCW 的配置迁移到真实的 Claude Code CLI 时,能够正常工作。 diff --git a/.claude/docs/HOOKS_DOCUMENTATION_INDEX.md b/.claude/docs/HOOKS_DOCUMENTATION_INDEX.md new file mode 100644 index 00000000..6e23d5b1 --- /dev/null +++ b/.claude/docs/HOOKS_DOCUMENTATION_INDEX.md @@ -0,0 +1,224 @@ +# Claude Code Hooks - 文档索引 + +本目录包含 Claude Code 官方钩子系统的完整文档和分析报告。 + +--- + +## 📚 官方文档(已下载) + +### 1. HOOKS_OFFICIAL_GUIDE.md +- **来源**: https://code.claude.com/docs/en/hooks-guide +- **内容**: 官方钩子指南,包含快速入门、常见用例、配置教程 +- **适用**: 初次使用钩子系统的开发者 + +### 2. HOOKS_OFFICIAL_REFERENCE.md +- **来源**: https://code.claude.com/docs/en/hooks +- **内容**: 完整的技术参考,包含所有事件的 schema、输入输出格式、配置选项 +- **适用**: 需要查阅具体事件参数和配置细节的开发者 + +### 3. HOOKS_QUICK_REFERENCE.md +- **内容**: 快速查阅指南,包含所有事件列表、配置模板、常见用例 +- **适用**: 需要快速查找特定配置或事件信息的开发者 + +--- + +## 📊 分析报告 + +### 4. HOOKS_ANALYSIS_REPORT.md +- **内容**: 当前 CCW 钩子实现 vs 官方标准对比分析 +- **包含**: + - 当前实现存在的问题 + - 事件名称对比 + - 配置结构对比 + - 修复建议和优先级 +- **适用**: 需要了解当前实现与官方标准差异的开发者 + +--- + +## 💻 示例代码 + +### 5. ../examples/hooks_bash_command_validator.py +- **来源**: https://github.com/anthropics/claude-code/blob/main/examples/hooks/bash_command_validator_example.py +- **内容**: 官方示例 - Bash 命令验证器 +- **功能**: 拦截 Bash 命令,建议使用 ripgrep 替代 grep +- **适用**: 学习如何编写 PreToolUse 钩子的开发者 + +--- + +## 🎯 官方钩子事件列表 + +### 官方支持的 12 个钩子事件 + +| # | 事件名称 | 触发时机 | 可阻止 | +|---|---------|---------|--------| +| 1 | `SessionStart` | 会话开始或恢复 | ❌ | +| 2 | `UserPromptSubmit` | 用户提交提示词前 | ✅ | +| 3 | `PreToolUse` | 工具调用前 | ✅ | +| 4 | `PermissionRequest` | 权限对话出现时 | ✅ | +| 5 | `PostToolUse` | 工具调用成功后 | ❌ | +| 6 | `PostToolUseFailure` | 工具调用失败后 | ❌ | +| 7 | `Notification` | 通知发送时 | ❌ | +| 8 | `SubagentStart` | 子代理生成时 | ❌ | +| 9 | `SubagentStop` | 子代理完成时 | ✅ | +| 10 | `Stop` | Claude完成响应时 | ✅ | +| 11 | `PreCompact` | 上下文压缩前 | ❌ | +| 12 | `SessionEnd` | 会话终止时 | ❌ | + +--- + +## ⚠️ 当前实现的主要问题 + +### 问题 1: 事件名称不符合官方标准 + +❌ **当前使用(错误):** +```json +{ + "hooks": { + "session-start": [], // 错误 + "session-end": [], // 错误 + "file-modified": [], // 不存在 + "context-request": [] // 不存在 + } +} +``` + +✅ **官方标准(正确):** +```json +{ + "hooks": { + "SessionStart": [], // 正确 + "SessionEnd": [], // 正确 + "PostToolUse": [], // 使用其他官方事件 + "PreToolUse": [] // 替代自定义事件 + } +} +``` + +### 问题 2: 配置结构不符合官方标准 + +❌ **当前结构(自定义):** +```json +{ + "hooks": { + "session-start": [ + { + "name": "...", + "enabled": true, + "handler": "internal:context", + "failMode": "silent" + } + ] + } +} +``` + +✅ **官方结构(标准):** +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "bash script.sh", + "timeout": 600 + } + ] + } + ] + } +} +``` + +--- + +## 🔗 外部资源 + +### 官方资源 +- **官方指南**: https://code.claude.com/docs/en/hooks-guide +- **官方参考**: https://code.claude.com/docs/en/hooks +- **GitHub 示例**: https://github.com/anthropics/claude-code/tree/main/examples/hooks +- **配置博客**: https://claude.com/blog/how-to-configure-hooks + +### 社区资源 +- [Claude Code Hooks 从入门到实战 - 知乎](https://zhuanlan.zhihu.com/p/1969164730326324920) +- [GitHub: claude-code-best-practices](https://github.com/xiaobei930/claude-code-best-practices) +- [Claude Code power user customization](https://claude.com/blog/how-to-configure-hooks) + +--- + +## 📖 推荐阅读顺序 + +### 对于初学者 +1. `HOOKS_QUICK_REFERENCE.md` - 快速了解钩子概念 +2. `HOOKS_OFFICIAL_GUIDE.md` - 学习如何配置和使用 +3. `hooks_bash_command_validator.py` - 查看示例代码 + +### 对于开发者(修复当前实现) +1. `HOOKS_ANALYSIS_REPORT.md` - 了解问题和修复方案 +2. `HOOKS_OFFICIAL_REFERENCE.md` - 查阅技术细节 +3. `HOOKS_OFFICIAL_GUIDE.md` - 学习最佳实践 + +### 对于高级用户 +1. `HOOKS_OFFICIAL_REFERENCE.md` - 完整技术参考 +2. 官方 GitHub 仓库 - 更多示例 +3. `HOOKS_QUICK_REFERENCE.md` - 快速查阅 + +--- + +## 🛠️ 快速开始 + +### 查看当前配置 +```bash +# 在 Claude Code CLI 中 +/hooks +``` + +### 创建第一个钩子(格式化代码) +`.claude/settings.json`: +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path' | xargs prettier --write" + } + ] + } + ] + } +} +``` + +### 调试钩子 +```bash +claude --debug # 查看详细执行日志 +``` + +在 CLI 中按 `Ctrl+O` 切换详细模式 + +--- + +## 📝 文档更新 + +- **创建时间**: 2026-02-01 +- **官方文档版本**: 最新(截至 2026-02-01) +- **下次更新建议**: 当 Claude Code 发布新版本时 + +--- + +## 🔍 搜索关键词 + +钩子、Hooks、事件、Events、SessionStart、PreToolUse、PostToolUse、配置、Configuration、命令、Command、Prompt、Agent、阻止、Block、通知、Notification + +--- + +**需要帮助?** + +参考 `HOOKS_QUICK_REFERENCE.md` 获取快速答案,或查阅 `HOOKS_OFFICIAL_REFERENCE.md` 获取完整技术细节。 diff --git a/.claude/docs/HOOKS_OFFICIAL_GUIDE.md b/.claude/docs/HOOKS_OFFICIAL_GUIDE.md new file mode 100644 index 00000000..60fab9a6 --- /dev/null +++ b/.claude/docs/HOOKS_OFFICIAL_GUIDE.md @@ -0,0 +1,124 @@ +# Claude Code Hooks - Official Guide + +> Complete official documentation from https://code.claude.com/docs/en/hooks-guide + +## Automate workflows with hooks + +Run shell commands automatically when Claude Code edits files, finishes tasks, or needs input. Format code, send notifications, validate commands, and enforce project rules. + +### Hook lifecycle + +Hooks fire at specific points during a Claude Code session. The official hook events are: + +| Event | When it fires | +| :------------------- | :--------------------------------------------------- | +| `SessionStart` | When a session begins or resumes | +| `UserPromptSubmit` | When you submit a prompt, before Claude processes it | +| `PreToolUse` | Before a tool call executes. Can block it | +| `PermissionRequest` | When a permission dialog appears | +| `PostToolUse` | After a tool call succeeds | +| `PostToolUseFailure` | After a tool call fails | +| `Notification` | When Claude Code sends a notification | +| `SubagentStart` | When a subagent is spawned | +| `SubagentStop` | When a subagent finishes | +| `Stop` | When Claude finishes responding | +| `PreCompact` | Before context compaction | +| `SessionEnd` | When a session terminates | + +### Hook handler types + +There are three types of hook handlers: + +1. **Command hooks** (`type: "command"`): Run a shell command +2. **Prompt hooks** (`type: "prompt"`): Use Claude model for single-turn evaluation +3. **Agent hooks** (`type: "agent"`): Spawn subagent with tool access + +### Configuration structure + +```json +{ + "hooks": { + "EventName": [ + { + "matcher": "ToolName|AnotherTool", + "hooks": [ + { + "type": "command", + "command": "bash /path/to/script.sh", + "timeout": 600, + "async": false + } + ] + } + ] + } +} +``` + +### Hook input (stdin) + +Common fields for all events: +- `session_id`: Current session ID +- `transcript_path`: Path to conversation JSON +- `cwd`: Current working directory +- `permission_mode`: Current permission mode +- `hook_event_name`: Name of the event that fired + +Event-specific fields depend on the event type. + +### Hook output (exit codes and stdout) + +- **Exit 0**: Success. Parse stdout for JSON decision +- **Exit 2**: Blocking error. stderr text becomes Claude's feedback +- **Any other code**: Non-blocking error + +### Tool matchers + +Available for: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest` + +Tool names: +- Built-in: `Bash`, `Edit`, `Write`, `Read`, `Glob`, `Grep`, `Task`, `WebFetch`, `WebSearch` +- MCP tools: `mcp____` (e.g., `mcp__github__search_repositories`) + +### Event matchers + +Different events match on different fields: +- `SessionStart`: `startup`, `resume`, `clear`, `compact` +- `SessionEnd`: `clear`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` +- `Notification`: `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog` +- `SubagentStart`/`SubagentStop`: agent type (e.g., `Bash`, `Explore`, `Plan`) +- `PreCompact`: `manual`, `auto` + +### Hook configuration locations + +| Location | Scope | +|----------|-------| +| `~/.claude/settings.json` | All your projects | +| `.claude/settings.json` | Single project | +| `.claude/settings.local.json` | Single project (gitignored) | +| Plugin `hooks/hooks.json` | When plugin enabled | +| Skill/Agent frontmatter | While component active | + +### Best practices + +✅ **DO:** +- Use command hooks for deterministic actions +- Use prompt hooks for judgment-based decisions +- Use agent hooks when verification requires file inspection +- Quote all shell variables: `"$VAR"` +- Use absolute paths with `$CLAUDE_PROJECT_DIR` +- Set appropriate timeouts +- Use async hooks for long-running operations +- Keep hooks fast (< 10 seconds by default) + +❌ **DON'T:** +- Trust input data blindly +- Use relative paths +- Put sensitive data in hook output +- Create infinite loops in Stop hooks +- Run blocking operations without async + +--- + +See https://code.claude.com/docs/en/hooks-guide for complete guide +See https://code.claude.com/docs/en/hooks for reference documentation diff --git a/.claude/docs/HOOKS_OFFICIAL_REFERENCE.md b/.claude/docs/HOOKS_OFFICIAL_REFERENCE.md new file mode 100644 index 00000000..88cf7e1a --- /dev/null +++ b/.claude/docs/HOOKS_OFFICIAL_REFERENCE.md @@ -0,0 +1,268 @@ +# Claude Code Hooks - Official Reference + +> Complete official reference from https://code.claude.com/docs/en/hooks + +## Hooks reference + +This is the complete technical reference for Claude Code hooks. + +### Hook events reference + +#### SessionStart + +**When it fires:** When a session begins or resumes + +**Matchers:** +- `startup` - New session +- `resume` - `--resume`, `--continue`, or `/resume` +- `clear` - `/clear` +- `compact` - Auto or manual compaction + +**Input schema:** +```json +{ + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/current/working/dir", + "permission_mode": "default", + "hook_event_name": "SessionStart", + "source": "startup|resume|clear|compact", + "model": "claude-sonnet-4-5-20250929" +} +``` + +**Output control:** +- Exit 0: Text written to stdout is added to Claude's context +- Can use `additionalContext` in JSON output +- Cannot block session start + +**Special variables:** +- `CLAUDE_ENV_FILE`: Write `export` statements to persist environment variables + +#### UserPromptSubmit + +**When it fires:** When user submits a prompt, before Claude processes it + +**Input schema:** +```json +{ + "session_id": "abc123", + "hook_event_name": "UserPromptSubmit", + "prompt": "User's prompt text here" +} +``` + +**Output control:** +- Exit 0: Plain text stdout is added as context +- `decision: "block"` prevents prompt processing +- `additionalContext` adds context to Claude + +#### PreToolUse + +**When it fires:** Before a tool call executes + +**Matchers:** Tool names (Bash, Edit, Write, Read, etc.) + +**Tool input schemas:** +- `Bash`: `command`, `description`, `timeout`, `run_in_background` +- `Write`: `file_path`, `content` +- `Edit`: `file_path`, `old_string`, `new_string`, `replace_all` +- `Read`: `file_path`, `offset`, `limit` +- `Glob`: `pattern`, `path` +- `Grep`: `pattern`, `path`, `glob`, `output_mode`, `-i`, `multiline` +- `WebFetch`: `url`, `prompt` +- `WebSearch`: `query`, `allowed_domains`, `blocked_domains` +- `Task`: `prompt`, `description`, `subagent_type`, `model` + +**Output control:** +- `permissionDecision`: `"allow"`, `"deny"`, `"ask"` +- `permissionDecisionReason`: Explanation +- `updatedInput`: Modify tool input before execution +- `additionalContext`: Add context to Claude + +#### PermissionRequest + +**When it fires:** When permission dialog appears + +**Input schema:** Similar to PreToolUse but fires when permission needed + +**Output control:** +- `decision.behavior`: `"allow"` or `"deny"` +- `decision.updatedInput`: Modify input before execution +- `decision.message`: For deny, tells Claude why + +#### PostToolUse + +**When it fires:** After a tool call succeeds + +**Input schema:** Includes both `tool_input` and `tool_response` + +**Output control:** +- `decision: "block"` to flag issue to Claude +- `additionalContext`: Add context +- `updatedMCPToolOutput`: For MCP tools, replace output + +#### PostToolUseFailure + +**When it fires:** After a tool call fails + +**Input schema:** Includes `error` and `is_interrupt` fields + +**Output control:** +- `additionalContext`: Provide context about the failure + +#### Notification + +**When it fires:** When Claude Code sends a notification + +**Matchers:** +- `permission_prompt` - Permission needed +- `idle_prompt` - Claude idle +- `auth_success` - Auth successful +- `elicitation_dialog` - Dialog shown + +**Input schema:** +```json +{ + "hook_event_name": "Notification", + "message": "Notification text", + "title": "Title", + "notification_type": "permission_prompt|idle_prompt|..." +} +``` + +#### SubagentStart + +**When it fires:** When subagent is spawned + +**Matchers:** Agent types (Bash, Explore, Plan, or custom) + +**Input schema:** +```json +{ + "hook_event_name": "SubagentStart", + "agent_id": "agent-abc123", + "agent_type": "Explore" +} +``` + +**Output control:** +- `additionalContext`: Add context to subagent + +#### SubagentStop + +**When it fires:** When subagent finishes + +**Input schema:** Similar to SubagentStart with `stop_hook_active` field + +#### Stop + +**When it fires:** When Claude finishes responding + +**Input schema:** +```json +{ + "hook_event_name": "Stop", + "stop_hook_active": false|true +} +``` + +**Output control:** +- `decision: "block"` prevents Claude from stopping +- `reason`: Required when blocking, tells Claude why to continue +- Check `stop_hook_active` to prevent infinite loops + +#### PreCompact + +**When it fires:** Before context compaction + +**Matchers:** +- `manual` - `/compact` +- `auto` - Auto-compact when context full + +**Input schema:** +```json +{ + "hook_event_name": "PreCompact", + "trigger": "manual|auto", + "custom_instructions": "" +} +``` + +#### SessionEnd + +**When it fires:** When session terminates + +**Matchers:** +- `clear` - `/clear` +- `logout` - User logged out +- `prompt_input_exit` - User exited during prompt +- `bypass_permissions_disabled` - Bypass disabled +- `other` - Other reasons + +**Input schema:** +```json +{ + "hook_event_name": "SessionEnd", + "reason": "clear|logout|..." +} +``` + +### Prompt-based hooks + +**Type:** `"prompt"` + +**Configuration:** +```json +{ + "type": "prompt", + "prompt": "Your prompt here. Use $ARGUMENTS for input JSON", + "model": "haiku", + "timeout": 30 +} +``` + +**Response schema:** +```json +{ + "ok": true|false, + "reason": "Explanation if ok is false" +} +``` + +### Agent-based hooks + +**Type:** `"agent"` + +**Configuration:** +```json +{ + "type": "agent", + "prompt": "Your prompt here. Use $ARGUMENTS for input JSON", + "model": "haiku", + "timeout": 60 +} +``` + +**Response schema:** Same as prompt hooks + +### Async hooks + +**For command hooks only:** +```json +{ + "type": "command", + "command": "...", + "async": true, + "timeout": 300 +} +``` + +- Doesn't block Claude's execution +- Cannot return decisions +- Output delivered on next conversation turn +- Max 50 turns per session + +--- + +See https://code.claude.com/docs/en/hooks for full reference diff --git a/.claude/docs/HOOKS_QUICK_REFERENCE.md b/.claude/docs/HOOKS_QUICK_REFERENCE.md new file mode 100644 index 00000000..08f70d5c --- /dev/null +++ b/.claude/docs/HOOKS_QUICK_REFERENCE.md @@ -0,0 +1,390 @@ +# Claude Code Hooks - 快速参考 + +## 12 个官方钩子事件 + +| # | 事件名称 | 触发时机 | 可阻止 | 匹配器 | +|---|---------|---------|--------|--------| +| 1 | `SessionStart` | 会话开始/恢复 | ❌ | startup, resume, clear, compact | +| 2 | `UserPromptSubmit` | 用户提交提示词前 | ✅ | ❌ 不支持 | +| 3 | `PreToolUse` | 工具调用前 | ✅ | 工具名称 | +| 4 | `PermissionRequest` | 权限对话时 | ✅ | 工具名称 | +| 5 | `PostToolUse` | 工具成功后 | ❌ | 工具名称 | +| 6 | `PostToolUseFailure` | 工具失败后 | ❌ | 工具名称 | +| 7 | `Notification` | 发送通知时 | ❌ | 通知类型 | +| 8 | `SubagentStart` | 子代理开始 | ❌ | 代理类型 | +| 9 | `SubagentStop` | 子代理完成 | ✅ | 代理类型 | +| 10 | `Stop` | Claude完成响应 | ✅ | ❌ 不支持 | +| 11 | `PreCompact` | 上下文压缩前 | ❌ | manual, auto | +| 12 | `SessionEnd` | 会话终止 | ❌ | 终止原因 | + +--- + +## 配置模板 + +### 基础结构 + +```json +{ + "hooks": { + "EventName": [ + { + "matcher": "pattern", + "hooks": [ + { + "type": "command|prompt|agent", + "command": "...", + "timeout": 600, + "async": false + } + ] + } + ] + } +} +``` + +--- + +## 工具名称(用于匹配器) + +### 内置工具 +``` +Bash, Edit, Write, Read, Glob, Grep, Task, WebFetch, WebSearch +``` + +### MCP 工具 +``` +mcp____ +mcp__memory__.* +mcp__.*__write.* +``` + +--- + +## Exit Codes + +| Code | 含义 | Claude反馈 | +|------|------|-----------| +| 0 | 成功 | 解析 stdout JSON,允许操作 | +| 2 | 阻止 | stderr 发送给 Claude,阻止操作 | +| 其他 | 错误 | stderr 仅在详细模式显示 | + +--- + +## 标准 JSON 输入(stdin) + +```json +{ + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/current/dir", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test" + } +} +``` + +--- + +## 标准 JSON 输出(stdout, exit 0) + +### PreToolUse +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow|deny|ask", + "permissionDecisionReason": "explanation" + } +} +``` + +### UserPromptSubmit +```json +{ + "decision": "block", + "reason": "explanation", + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": "context string" + } +} +``` + +### Stop +```json +{ + "decision": "block", + "reason": "Must complete tasks X, Y, Z" +} +``` + +--- + +## 处理器类型 + +### 1. Command Hook +```json +{ + "type": "command", + "command": "bash /path/to/script.sh", + "timeout": 600, + "async": false +} +``` + +### 2. Prompt Hook +```json +{ + "type": "prompt", + "prompt": "Evaluate: $ARGUMENTS", + "model": "haiku", + "timeout": 30 +} +``` + +响应格式: +```json +{ + "ok": true|false, + "reason": "explanation if ok is false" +} +``` + +### 3. Agent Hook +```json +{ + "type": "agent", + "prompt": "Verify tests pass: $ARGUMENTS", + "model": "haiku", + "timeout": 60 +} +``` + +响应格式:与 Prompt Hook 相同 + +--- + +## 环境变量 + +### 标准变量 +```bash +$CLAUDE_PROJECT_DIR # 项目根目录 +$CLAUDE_PLUGIN_ROOT # 插件根目录(插件内部使用) +$CLAUDE_CODE_REMOTE # "true" 在远程环境 +``` + +### SessionStart 特殊变量 +```bash +$CLAUDE_ENV_FILE # 持久化环境变量的文件路径 +``` + +用法: +```bash +#!/bin/bash +if [ -n "$CLAUDE_ENV_FILE" ]; then + echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE" +fi +``` + +--- + +## 常见用例 + +### 1. 格式化代码 +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path' | xargs prettier --write" + } + ] + } + ] + } +} +``` + +### 2. 阻止危险命令 +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh" + } + ] + } + ] + } +} +``` + +脚本 `block-rm.sh`: +```bash +#!/bin/bash +COMMAND=$(jq -r '.tool_input.command') +if echo "$COMMAND" | grep -q 'rm -rf'; then + echo "Blocked: rm -rf is not allowed" >&2 + exit 2 +fi +exit 0 +``` + +### 3. 通知用户 +```json +{ + "hooks": { + "Notification": [ + { + "matcher": "permission_prompt", + "hooks": [ + { + "type": "command", + "command": "osascript -e 'display notification \"Claude needs your attention\"'" + } + ] + } + ] + } +} +``` + +### 4. 确认任务完成 +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "prompt", + "prompt": "Check if all tasks complete: $ARGUMENTS" + } + ] + } + ] + } +} +``` + +### 5. 异步运行测试 +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "/path/to/run-tests.sh", + "async": true, + "timeout": 120 + } + ] + } + ] + } +} +``` + +### 6. 会话开始注入上下文 +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "echo 'Reminder: use Bun, not npm'" + } + ] + } + ] + } +} +``` + +--- + +## 配置位置 + +| 位置 | 作用域 | 可共享 | +|------|--------|--------| +| `~/.claude/settings.json` | 全局用户 | ❌ | +| `.claude/settings.json` | 单个项目 | ✅ | +| `.claude/settings.local.json` | 单个项目 | ❌ (gitignored) | +| 插件 `hooks/hooks.json` | 插件启用时 | ✅ | +| Skill/Agent frontmatter | 组件活动时 | ✅ | + +--- + +## 调试技巧 + +### 1. 详细模式 +```bash +claude --debug # 查看完整钩子执行细节 +Ctrl+O # 切换详细模式(实时) +``` + +### 2. 测试钩子脚本 +```bash +echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh +echo $? # 检查退出码 +``` + +### 3. 检查钩子配置 +``` +/hooks # 交互式钩子管理器 +``` + +--- + +## 最佳实践 + +✅ **推荐:** +- 总是引用 shell 变量:`"$VAR"` +- 使用绝对路径:`"$CLAUDE_PROJECT_DIR"/script.sh` +- 设置合理的超时时间 +- 验证和清理输入数据 +- 在 Stop 钩子中检查 `stop_hook_active` +- 使用 async 进行长时间运行的操作 + +❌ **避免:** +- 直接信任输入数据 +- 使用相对路径 +- 在钩子输出中暴露敏感数据 +- 创建无限循环(尤其在 Stop 钩子) +- 没有设置超时的阻塞操作 + +--- + +## 官方资源 + +- **指南**: https://code.claude.com/docs/en/hooks-guide +- **参考**: https://code.claude.com/docs/en/hooks +- **示例**: https://github.com/anthropics/claude-code/tree/main/examples/hooks + +--- + +## 本地文档 + +- `HOOKS_OFFICIAL_GUIDE.md` - 官方指南中文版 +- `HOOKS_OFFICIAL_REFERENCE.md` - 官方参考中文版 +- `HOOKS_ANALYSIS_REPORT.md` - 当前实现对比分析 +- `hooks_bash_command_validator.py` - 官方示例脚本 diff --git a/.claude/examples/hooks_bash_command_validator.py b/.claude/examples/hooks_bash_command_validator.py new file mode 100644 index 00000000..d6e5bedb --- /dev/null +++ b/.claude/examples/hooks_bash_command_validator.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Claude Code Hook: Bash Command Validator +========================================= +This hook runs as a PreToolUse hook for the Bash tool. +It validates bash commands against a set of rules before execution. +In this case it changes grep calls to using rg. + +Read more about hooks here: https://code.claude.com/docs/en/hooks + +Make sure to change your path to your actual script. + +Configuration for .claude/settings.json: + +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 /path/to/bash_command_validator.py" + } + ] + } + ] + } +} + +""" + +import json +import re +import sys + +# Define validation rules as a list of (regex pattern, message) tuples +_VALIDATION_RULES = [ + ( + r"^grep\b(?!.*\|)", + "Use 'rg' (ripgrep) instead of 'grep' for better performance and features", + ), + ( + r"^find\s+\S+\s+-name\b", + "Use 'rg --files | rg pattern' or 'rg --files -g pattern' instead of 'find -name' for better performance", + ), +] + + +def _validate_command(command: str) -> list[str]: + issues = [] + for pattern, message in _VALIDATION_RULES: + if re.search(pattern, command): + issues.append(message) + return issues + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON input: {e}", file=sys.stderr) + # Exit code 1 shows stderr to the user but not to Claude + sys.exit(1) + + tool_name = input_data.get("tool_name", "") + if tool_name != "Bash": + sys.exit(0) + + tool_input = input_data.get("tool_input", {}) + command = tool_input.get("command", "") + + if not command: + sys.exit(0) + + issues = _validate_command(command) + if issues: + for message in issues: + print(f"• {message}", file=sys.stderr) + # Exit code 2 blocks tool call and shows stderr to Claude + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/ccw/frontend/src/components/commands/CommandGroupAccordion.tsx b/ccw/frontend/src/components/commands/CommandGroupAccordion.tsx new file mode 100644 index 00000000..f319cd40 --- /dev/null +++ b/ccw/frontend/src/components/commands/CommandGroupAccordion.tsx @@ -0,0 +1,401 @@ +// ======================================== +// CommandGroupAccordion Component +// ======================================== +// Accordion component for displaying command groups with toggle switches + +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { ChevronDown, ChevronRight, Eye, EyeOff } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/Badge'; +import { Switch } from '@/components/ui/Switch'; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@/components/ui/Collapsible'; +import type { Command } from '@/lib/api'; + +export interface CommandGroupAccordionProps { + /** Group name (e.g., 'cli', 'workflow', 'workflow/review') */ + groupName: string; + /** Commands in this group */ + commands: Command[]; + /** Is this group expanded */ + isExpanded: boolean; + /** Toggle expand/collapse */ + onToggleExpand: (groupName: string) => void; + /** Toggle individual command enabled state */ + onToggleCommand: (name: string, enabled: boolean) => void; + /** Toggle all commands in group */ + onToggleGroup: (groupName: string, enable: boolean) => void; + /** Is toggling in progress */ + isToggling: boolean; + /** Show disabled commands */ + showDisabled?: boolean; +} + +/** + * Get icon for a command group + * Uses top-level parent's icon for nested groups + */ +function getGroupIcon(groupName: string): React.ReactNode { + const groupIcons: Record = { + cli: 'terminal', + workflow: 'git-branch', + memory: 'brain', + task: 'clipboard-list', + issue: 'alert-circle', + loop: 'repeat', + skill: 'sparkles', + other: 'folder', + }; + + const topLevel = groupName.split('/')[0]; + return groupIcons[topLevel] || 'folder'; +} + +/** + * Get color class for a command group + * Uses top-level parent's color for nested groups + */ +function getGroupColorClass(groupName: string): string { + const groupColors: Record = { + cli: 'text-primary bg-primary/10', + workflow: 'text-success bg-success/10', + memory: 'text-indigo bg-indigo/10', + task: 'text-warning bg-warning/10', + issue: 'text-destructive bg-destructive/10', + loop: 'text-purple bg-purple/10', + skill: 'text-pink bg-pink/10', + other: 'text-muted-foreground bg-muted', + }; + + const topLevel = groupName.split('/')[0]; + return groupColors[topLevel] || 'text-muted-foreground bg-muted'; +} + +/** + * Format group name for display + * Converts nested paths like 'workflow/review' -> 'Workflow > Review' + */ +function formatGroupName(groupName: string): string { + if (!groupName.includes('/')) { + return groupName; + } + + const parts = groupName.split('/'); + return parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' > '); +} + +/** + * Get Lucide icon component by name + */ +function getIconComponent(iconName: string): React.ComponentType<{ className?: string }> { + const iconMap: Record> = { + terminal: ({ className }) => ( + + + + + ), + 'git-branch': ({ className }) => ( + + + + + + + ), + brain: ({ className }) => ( + + + + + ), + 'clipboard-list': ({ className }) => ( + + + + + + + + + ), + 'alert-circle': ({ className }) => ( + + + + + + ), + repeat: ({ className }) => ( + + + + + + + ), + sparkles: ({ className }) => ( + + + + + + + + ), + folder: ({ className }) => ( + + + + ), + }; + + return iconMap[iconName] || iconMap.folder; +} + +/** + * CommandGroupAccordion component + * Displays a collapsible group of commands with toggle switches + */ +export function CommandGroupAccordion({ + groupName, + commands, + isExpanded, + onToggleExpand, + onToggleCommand, + onToggleGroup, + isToggling, + showDisabled = false, +}: CommandGroupAccordionProps) { + const { formatMessage } = useIntl(); + + const enabledCommands = commands.filter((cmd) => cmd.enabled); + const disabledCommands = commands.filter((cmd) => !cmd.enabled); + const allEnabled = enabledCommands.length === commands.length && commands.length > 0; + + // Filter commands based on showDisabled setting + const visibleCommands = showDisabled ? commands : enabledCommands; + + const iconName = getGroupIcon(groupName); + const colorClass = getGroupColorClass(groupName); + const displayName = formatGroupName(groupName); + const IconComponent = getIconComponent(iconName); + const indentLevel = (groupName.match(/\//g) || []).length; + + const handleToggleGroup = (checked: boolean) => { + onToggleGroup(groupName, checked); + }; + + return ( +
0 && 'ml-5')} style={indentLevel > 0 ? { marginLeft: `${indentLevel * 20}px` } : undefined}> + onToggleExpand(groupName)}> + {/* Group Header */} +
+ +
+ {isExpanded ? ( + + ) : ( + + )} +
+ +
+
+

{displayName}

+

+ {enabledCommands.length}/{commands.length} {formatMessage({ id: 'commands.group.enabled' })} +

+
+
+
+ +
+ {/* Group Toggle Switch */} + + + {commands.length} + +
+
+ + {/* Group Content - Commands Table */} + +
+ + + + + + + + + + + + + + + + + {visibleCommands.map((command) => ( + + ))} + {visibleCommands.length === 0 && ( + + + + )} + +
+ {formatMessage({ id: 'commands.table.name' })} + + {formatMessage({ id: 'commands.table.description' })} + + {formatMessage({ id: 'commands.table.scope' })} + + {formatMessage({ id: 'commands.table.status' })} +
+ {showDisabled + ? formatMessage({ id: 'commands.group.noCommands' }) + : formatMessage({ id: 'commands.group.noEnabledCommands' })} +
+
+
+
+
+ ); +} + +/** + * CommandRow component - Internal component for table row + */ +interface CommandRowProps { + command: Command; + onToggle: (name: string, enabled: boolean) => void; + disabled?: boolean; +} + +function CommandRow({ command, onToggle, disabled }: CommandRowProps) { + const { formatMessage } = useIntl(); + const isDisabled = !command.enabled; + + return ( + + + /{command.name} + + +
+ {command.description || formatMessage({ id: 'commands.card.noDescription' })} +
+ + + {command.location || 'project'} + + +
+ onToggle(command.name, checked)} + disabled={disabled} + className="data-[state=checked]:bg-primary" + /> +
+ + + ); +} + +export default CommandGroupAccordion; diff --git a/ccw/frontend/src/components/commands/LocationSwitcher.tsx b/ccw/frontend/src/components/commands/LocationSwitcher.tsx new file mode 100644 index 00000000..647e05ba --- /dev/null +++ b/ccw/frontend/src/components/commands/LocationSwitcher.tsx @@ -0,0 +1,78 @@ +// ======================================== +// LocationSwitcher Component +// ======================================== +// Toggle between Project and User command locations + +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { Folder, User } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface LocationSwitcherProps { + /** Current selected location */ + currentLocation: 'project' | 'user'; + /** Callback when location changes */ + onLocationChange: (location: 'project' | 'user') => void; + /** Number of project commands (optional, for display) */ + projectCount?: number; + /** Number of user commands (optional, for display) */ + userCount?: number; + /** Disabled state */ + disabled?: boolean; + /** Translation prefix (default: 'commands') */ + translationPrefix?: 'commands' | 'skills'; +} + +/** + * LocationSwitcher component + * Toggle switch for Project vs User command location + */ +export function LocationSwitcher({ + currentLocation, + onLocationChange, + projectCount, + userCount, + disabled = false, + translationPrefix = 'commands', +}: LocationSwitcherProps) { + const { formatMessage } = useIntl(); + + return ( +
+ + +
+ ); +} + +export default LocationSwitcher; diff --git a/ccw/frontend/src/components/commands/index.ts b/ccw/frontend/src/components/commands/index.ts new file mode 100644 index 00000000..61e476d8 --- /dev/null +++ b/ccw/frontend/src/components/commands/index.ts @@ -0,0 +1,9 @@ +// ======================================== +// Commands Components Barrel Export +// ======================================== + +export { CommandGroupAccordion } from './CommandGroupAccordion'; +export type { CommandGroupAccordionProps } from './CommandGroupAccordion'; + +export { LocationSwitcher } from './LocationSwitcher'; +export type { LocationSwitcherProps } from './LocationSwitcher'; diff --git a/ccw/frontend/src/components/hook/HookCard.tsx b/ccw/frontend/src/components/hook/HookCard.tsx index 90c769fc..56c705b7 100644 --- a/ccw/frontend/src/components/hook/HookCard.tsx +++ b/ccw/frontend/src/components/hook/HookCard.tsx @@ -20,7 +20,7 @@ import { cn } from '@/lib/utils'; // ========== Types ========== -export type HookTriggerType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop'; +export type HookTriggerType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop'; export interface HookCardData { name: string; @@ -45,6 +45,8 @@ export interface HookCardProps { function getTriggerIcon(trigger: HookTriggerType) { switch (trigger) { + case 'SessionStart': + return '🎬'; case 'UserPromptSubmit': return '⚡'; case 'PreToolUse': @@ -60,6 +62,8 @@ function getTriggerIcon(trigger: HookTriggerType) { function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' { switch (trigger) { + case 'SessionStart': + return 'default'; case 'UserPromptSubmit': return 'default'; case 'PreToolUse': diff --git a/ccw/frontend/src/components/hook/HookFormDialog.tsx b/ccw/frontend/src/components/hook/HookFormDialog.tsx index ec76a976..43f08982 100644 --- a/ccw/frontend/src/components/hook/HookFormDialog.tsx +++ b/ccw/frontend/src/components/hook/HookFormDialog.tsx @@ -147,6 +147,7 @@ export function HookFormDialog({ }; const TRIGGER_OPTIONS: { value: HookTriggerType; label: string }[] = [ + { value: 'SessionStart', label: 'cliHooks.trigger.SessionStart' }, { value: 'UserPromptSubmit', label: 'cliHooks.trigger.UserPromptSubmit' }, { value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' }, { value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' }, diff --git a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx index fa485678..03d2259d 100644 --- a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx +++ b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx @@ -32,7 +32,7 @@ export interface HookTemplate { name: string; description: string; category: TemplateCategory; - trigger: 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop'; + trigger: 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop'; command: string; args?: string[]; matcher?: string; @@ -57,79 +57,28 @@ export interface HookQuickTemplatesProps { */ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ { - id: 'ccw-status-tracker', - name: 'CCW Status Tracker', - description: 'Parse CCW status.json and display current/next command', + id: 'session-start-notify', + name: 'Session Start Notify', + description: 'Notify dashboard when a new workflow session is created', + category: 'notification', + trigger: 'SessionStart', + command: 'node', + args: [ + '-e', + 'const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_CREATED",timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' + ] + }, + { + id: 'session-state-watch', + name: 'Session State Watch', + description: 'Watch for session metadata file changes (workflow-session.json)', category: 'notification', trigger: 'PostToolUse', - matcher: 'Write', - command: 'bash', + matcher: 'Write|Edit', + command: 'node', args: [ - '-c', - 'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && [[ "$FILE_PATH" == *"status.json" ]] && ccw hook parse-status --path "$FILE_PATH" || true' - ] - }, - { - id: 'ccw-notify', - name: 'CCW Dashboard Notify', - description: 'Send notifications to CCW dashboard when files are written', - category: 'notification', - trigger: 'PostToolUse', - matcher: 'Write', - command: 'bash', - args: [ - '-c', - 'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"file_written\\",\\"filePath\\":\\"$FILE_PATH\\"}" http://localhost:3456/api/hook || true' - ] - }, - { - id: 'codexlens-update', - name: 'CodexLens Auto-Update', - description: 'Update CodexLens index when files are written or edited', - category: 'indexing', - trigger: 'Stop', - command: 'bash', - args: [ - '-c', - 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -d ".codexlens" ] && [ -n "$FILE" ] && (python -m codexlens update "$FILE" --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update "$FILE" --json 2>/dev/null || true)' - ] - }, - { - id: 'git-add', - name: 'Auto Git Stage', - description: 'Automatically stage written files to git', - category: 'automation', - trigger: 'PostToolUse', - matcher: 'Write', - command: 'bash', - args: [ - '-c', - 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); [ -n "$FILE" ] && git add "$FILE" 2>/dev/null || true' - ] - }, - { - id: 'lint-check', - name: 'Auto ESLint', - description: 'Run ESLint on JavaScript/TypeScript files after write', - category: 'automation', - trigger: 'PostToolUse', - matcher: 'Write', - command: 'bash', - args: [ - '-c', - 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); if [[ "$FILE" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$FILE" --fix 2>/dev/null || true; fi' - ] - }, - { - id: 'log-tool', - name: 'Tool Usage Logger', - description: 'Log all tool executions to a file for audit trail', - category: 'automation', - trigger: 'PostToolUse', - command: 'bash', - args: [ - '-c', - 'mkdir -p "$HOME/.claude"; INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty" 2>/dev/null); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); echo "[$(date)] Tool: $TOOL, File: $FILE" >> "$HOME/.claude/tool-usage.log"' + '-e', + 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/workflow-session\\.json$|session-metadata\\.json$/.test(file)){const fs=require("fs");try{const content=fs.readFileSync(file,"utf8");const data=JSON.parse(content);const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_STATE_CHANGED",file:file,sessionId:data.session_id||"",status:data.status||"unknown",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}catch(e){}}' ] } ] as const; diff --git a/ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx b/ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx index 724896d8..5aae27b4 100644 --- a/ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx +++ b/ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx @@ -126,7 +126,7 @@ function ExternalDependenciesSection({ dependencies }: ExternalDependenciesSecti {dependencies.map((dep, index) => ( {dep.package} - {dep.version && @{dep.version}} + {dep.version && @{dep.version}} ))} diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx index 1c724a1a..ff583cd1 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx @@ -19,11 +19,11 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio // Simplify tool name (e.g., gemini-2.5-pro -> gemini) const toolNameShort = execution.tool.split('-')[0]; - // Status color mapping + // Status color mapping - using softer, semantic colors const statusColor = { - running: 'bg-green-500 animate-pulse', - completed: 'bg-blue-500', - error: 'bg-red-500', + running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse', + completed: 'bg-slate-400 dark:bg-slate-500', + error: 'bg-rose-500', }[execution.status]; return ( @@ -31,34 +31,36 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio value={execution.id} onClick={onClick} className={cn( - 'gap-2 text-xs px-3 py-1.5', + 'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group', isActive - ? 'bg-primary text-primary-foreground' - : 'bg-muted/50 hover:bg-muted/70', - 'transition-colors' + ? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm' + : 'bg-muted/30 hover:bg-muted/50 border-border/30', + 'transition-all' )} > {/* Status indicator dot */} - + {/* Simplified tool name */} - {toolNameShort} + {toolNameShort} - {/* Execution mode */} - {execution.mode} - - {/* Line count statistics */} - - {execution.output.length} lines + {/* Execution mode - show on hover */} + + {execution.mode} - {/* Close button */} + {/* Line count statistics - show on hover */} + + {execution.output.length} + + + {/* Close button - show on hover */} ); diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonCard.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonCard.tsx index e18ce985..739d86f1 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonCard.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonCard.tsx @@ -18,6 +18,8 @@ import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { cn } from '@/lib/utils'; import { JsonField } from './JsonField'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; // ========== Types ========== @@ -37,6 +39,7 @@ export interface JsonCardProps { type TypeConfig = { icon: typeof Wrench; label: string; + shortLabel: string; color: string; bg: string; }; @@ -45,38 +48,44 @@ const TYPE_CONFIGS: Record = { tool_call: { icon: Wrench, label: 'Tool Call', - color: 'text-green-400', - bg: 'bg-green-950/30 border-green-900/50', + shortLabel: 'Tool', + color: 'text-indigo-600 dark:text-indigo-400', + bg: 'border-l-indigo-500', }, metadata: { icon: Info, label: 'Metadata', - color: 'text-yellow-400', - bg: 'bg-yellow-950/30 border-yellow-900/50', + shortLabel: 'Info', + color: 'text-slate-600 dark:text-slate-400', + bg: 'border-l-slate-400', }, system: { icon: Settings, label: 'System', - color: 'text-blue-400', - bg: 'bg-blue-950/30 border-blue-900/50', + shortLabel: 'Sys', + color: 'text-slate-600 dark:text-slate-400', + bg: 'border-l-slate-400', }, stdout: { icon: Code, label: 'Data', - color: 'text-cyan-400', - bg: 'bg-cyan-950/30 border-cyan-900/50', + shortLabel: 'Out', + color: 'text-teal-600 dark:text-teal-400', + bg: 'border-l-teal-500', }, stderr: { icon: AlertTriangle, label: 'Error', - color: 'text-red-400', - bg: 'bg-red-950/30 border-red-900/50', + shortLabel: 'Err', + color: 'text-rose-600 dark:text-rose-400', + bg: 'border-l-rose-500', }, thought: { icon: Brain, label: 'Thought', - color: 'text-purple-400', - bg: 'bg-purple-950/30 border-purple-900/50', + shortLabel: '💭', + color: 'text-violet-600 dark:text-violet-400', + bg: 'border-l-violet-500', }, }; @@ -88,97 +97,78 @@ export function JsonCard({ timestamp, onCopy, }: JsonCardProps) { - const [isExpanded, setIsExpanded] = useState(false); - const [showRaw, setShowRaw] = useState(false); - - const entries = Object.entries(data); - const visibleCount = isExpanded ? entries.length : 3; - const hasMore = entries.length > 3; + const [isExpanded, setIsExpanded] = useState(true); const config = TYPE_CONFIGS[type]; - const Icon = config.icon; - return ( -
- {/* Header */} -
setIsExpanded(!isExpanded)} - > -
- - {config.label} - - {entries.length} - -
+ // Check if data has a 'content' field + const hasContentField = 'content' in data && typeof data.content === 'string'; + const content = hasContentField ? (data.content as string) : ''; -
- {timestamp && ( - - {new Date(timestamp).toLocaleTimeString()} - + // If has content field, render as streaming output + if (hasContentField) { + // Check if content looks like markdown + const isMarkdown = content.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m); + + return ( +
+
+ {isMarkdown ? ( +
+ + {content} + +
+ ) : ( +
+ {content} +
)} - - -
+ ); + } + + // Otherwise, render as card with fields + const entries = Object.entries(data).filter(([key]) => + key !== 'type' && key !== 'timestamp' && key !== 'role' && key !== 'id' + ); + const visibleCount = isExpanded ? entries.length : 1; + const hasMore = entries.length > 1; + + const handleCopyCard = () => { + const content = JSON.stringify(data, null, 2); + navigator.clipboard.writeText(content); + }; + + return ( +
+ {/* Copy button - show on hover */} + {/* Content */} - {showRaw ? ( -
-          {JSON.stringify(data, null, 2)}
-        
- ) : ( -
- {entries.slice(0, visibleCount).map(([key, value]) => ( - - ))} - {hasMore && ( - - )} -
- )} +
+ {entries.slice(0, visibleCount).map(([key, value]) => ( + + ))} + {hasMore && ( + + )} +
); } diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonField.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonField.tsx index 88bb1002..1f985cdd 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonField.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/components/JsonField.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Copy } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface JsonFieldProps { @@ -8,32 +9,70 @@ export interface JsonFieldProps { export function JsonField({ fieldName, value }: JsonFieldProps) { const [isExpanded, setIsExpanded] = useState(false); + const [copied, setCopied] = useState(false); const isObject = value !== null && typeof value === 'object'; const isNested = isObject && (Array.isArray(value) || Object.keys(value).length > 0); + // Skip rendering certain fields + if (fieldName === 'type' || fieldName === 'timestamp' || fieldName === 'role' || fieldName === 'id' || fieldName === 'content') { + return null; + } + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + const renderPrimitiveValue = (val: unknown): React.ReactNode => { if (val === null) return null; - if (typeof val === 'boolean') return {String(val)}; + if (typeof val === 'boolean') return {String(val)}; if (typeof val === 'number') return {String(val)}; if (typeof val === 'string') { - // Check if it's a JSON string const trimmed = val.trim(); + const isLong = trimmed.length > 80; if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - return "{trimmed.substring(0, 30)}..."; + return ( + + "{trimmed.substring(0, isLong ? 50 : trimmed.length)}{isLong ? '...' : ''}" + {isLong && ( + + )} + + ); } - return "{val}"; + return ( + + "{val}" + {isLong && ( + + )} + + ); } return String(val); }; return (
{/* Field name */} - + {fieldName} @@ -46,27 +85,27 @@ export function JsonField({ fieldName, value }: JsonFieldProps) {
setIsExpanded(e.currentTarget.open)} - className="group" + className="group/summary" > - - + + {isExpanded ? '▼' : '▶'} {Array.isArray(value) ? ( - Array[{value.length}] + [{value.length}] ) : ( - Object{'{'}{Object.keys(value).length}{'}'} + {'{'}{Object.keys(value).length}{'}'} )} {isExpanded && ( -
+
{Array.isArray(value) ? value.map((item, i) => ( -
+
{typeof item === 'object' && item !== null ? ( ) : ( - renderPrimitiveValue(item) + {renderPrimitiveValue(item)} )}
)) @@ -78,7 +117,9 @@ export function JsonField({ fieldName, value }: JsonFieldProps) { )}
) : ( -
{renderPrimitiveValue(value)}
+
+ {renderPrimitiveValue(value)} +
)}
diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/components/OutputLine.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/components/OutputLine.tsx index 759d47ae..821ccad7 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/components/OutputLine.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/components/OutputLine.tsx @@ -23,45 +23,23 @@ export interface OutputLineProps { // ========== Helper Functions ========== /** - * Get the icon component for a given output line type + * Get the icon component with color for a given output line type */ function getOutputLineIcon(type: OutputLineProps['line']['type']) { switch (type) { case 'thought': - return ; + return ; case 'system': - return ; + return ; case 'stderr': - return ; + return ; case 'metadata': - return ; + return ; case 'tool_call': - return ; + return ; case 'stdout': default: - return ; - } -} - -/** - * Get the CSS class name for a given output line type - * Reuses the existing implementation from LogBlock utils - */ -function getOutputLineClass(type: OutputLineProps['line']['type']): string { - switch (type) { - case 'thought': - return 'text-purple-400'; - case 'system': - return 'text-blue-400'; - case 'stderr': - return 'text-red-400'; - case 'metadata': - return 'text-yellow-400'; - case 'tool_call': - return 'text-green-400'; - case 'stdout': - default: - return 'text-foreground'; + return ; } } @@ -72,8 +50,8 @@ function getOutputLineClass(type: OutputLineProps['line']['type']): string { * * Features: * - Auto-detects JSON content and renders with JsonCard - * - Shows appropriate icon based on line type - * - Applies color styling based on line type + * - Shows colored icon based on line type + * - Different card styles for different types * - Supports copy functionality */ export function OutputLine({ line, onCopy }: OutputLineProps) { @@ -81,25 +59,22 @@ export function OutputLine({ line, onCopy }: OutputLineProps) { const jsonDetection = useMemo(() => detectJsonInLine(line.content), [line.content]); return ( -
- {/* Icon indicator */} - - {getOutputLineIcon(line.type)} - - - {/* Content area */} -
- {jsonDetection.isJson && jsonDetection.parsed ? ( - onCopy?.(line.content)} - /> - ) : ( - {line.content} - )} -
+
+ {jsonDetection.isJson && jsonDetection.parsed ? ( + onCopy?.(line.content)} + /> + ) : ( +
+ + {getOutputLineIcon(line.type)} + + {line.content} +
+ )}
); } diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/index.ts b/ccw/frontend/src/components/shared/CliStreamMonitor/index.ts index d6c134f0..063163e4 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/index.ts +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/index.ts @@ -3,8 +3,11 @@ // ======================================== // Main components -export { CliStreamMonitorNew as CliStreamMonitor } from './CliStreamMonitorNew'; -export type { CliStreamMonitorNewProps as CliStreamMonitorProps } from './CliStreamMonitorNew'; +export { default as CliStreamMonitor } from '../CliStreamMonitorLegacy'; +export type { CliStreamMonitorProps } from '../CliStreamMonitorLegacy'; + +export { CliStreamMonitorNew } from './CliStreamMonitorNew'; +export type { CliStreamMonitorNewProps } from './CliStreamMonitorNew'; export { default as CliStreamMonitorLegacy } from '../CliStreamMonitorLegacy'; export type { CliStreamMonitorProps as CliStreamMonitorLegacyProps } from '../CliStreamMonitorLegacy'; diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/AssistantMessage.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/AssistantMessage.tsx index ceaea473..5aa749d9 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/AssistantMessage.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/AssistantMessage.tsx @@ -19,27 +19,27 @@ function StatusIndicator({ status, duration }: StatusIndicatorProps) { if (status === 'thinking') { return ( - + {formatMessage({ id: 'cliMonitor.thinking' })} - 🟡 + ); } if (status === 'streaming') { return ( - + {formatMessage({ id: 'cliMonitor.streaming' })} - 🔵 + ); } if (status === 'error') { return ( - - Error - + + Err + ); } @@ -47,7 +47,7 @@ function StatusIndicator({ status, duration }: StatusIndicatorProps) { if (duration !== undefined) { const seconds = (duration / 1000).toFixed(1); return ( - + {seconds}s ); @@ -110,28 +110,28 @@ export function AssistantMessage({ return (
{/* Header */}
setIsExpanded(!isExpanded)} > - - + + {modelName} -
+
@@ -141,49 +141,43 @@ export function AssistantMessage({ {/* Content */} {isExpanded && ( <> -
-
-
+
+
+
{content}
- {/* Metadata Footer */} + {/* Metadata Footer - simplified */}
e.stopPropagation()} > -
- {tokenCount !== undefined && ( - {formatMessage({ id: 'cliMonitor.tokens' }, { count: tokenCount.toLocaleString() })} - )} +
{duration !== undefined && ( - {formatMessage({ id: 'cliMonitor.duration' }, { value: formatDuration(duration) })} + {formatDuration(duration)} + )} + {tokenCount !== undefined && ( + + {tokenCount.toLocaleString()} tok + )} - {modelName && {formatMessage({ id: 'cliMonitor.model' }, { name: modelName })}}
diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/ErrorMessage.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/ErrorMessage.tsx index 401c126f..2f8c7210 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/ErrorMessage.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/ErrorMessage.tsx @@ -25,46 +25,38 @@ export function ErrorMessage({ className }: ErrorMessageProps) { const { formatMessage } = useIntl(); - const timeString = timestamp - ? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false }) - : ''; return (
- {/* Header */} -
- - {timeString && ( - - [{timeString}] - - )} - + {/* Header - simplified */} +
+ + {title}
{/* Content */} -
-

+

+

{message}

{/* Actions */} {(onRetry || onDismiss) && ( -
+
{onRetry && ( @@ -74,7 +66,7 @@ export function ErrorMessage({ variant="ghost" size="sm" onClick={onDismiss} - className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground" + className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground" > {formatMessage({ id: 'cliMonitor.dismiss' })} diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/SystemMessage.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/SystemMessage.tsx index f6928551..78fe6f48 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/SystemMessage.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/SystemMessage.tsx @@ -23,37 +23,43 @@ export function SystemMessage({ }: SystemMessageProps) { const [isExpanded, setIsExpanded] = useState(false); const timeString = timestamp - ? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false }) + ? new Date(timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit' + }) : ''; return (
{/* Header */}
content && setIsExpanded(!isExpanded)} > - - - [{timeString}] - - + + {title} + {timestamp && ( + + {timeString} + + )} {metadata && ( - + {metadata} )} {content && ( @@ -62,7 +68,7 @@ export function SystemMessage({ {/* Expandable Content */} {isExpanded && content && ( -
+
{content}
diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/UserMessage.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/UserMessage.tsx index 565f99f6..eeb8095a 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/messages/UserMessage.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/messages/UserMessage.tsx @@ -26,9 +26,6 @@ export function UserMessage({ const { formatMessage } = useIntl(); const [isExpanded, setIsExpanded] = useState(true); const [copied, setCopied] = useState(false); - const timeString = timestamp - ? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false }) - : ''; // Auto-reset copied state useEffect(() => { @@ -46,51 +43,45 @@ export function UserMessage({ return (
- {/* Header */} + {/* Header - simplified */}
setIsExpanded(!isExpanded)} > - - + + {formatMessage({ id: 'cliMonitor.user' })} - {timeString && ( - - [{timeString}] - - )}
{/* Content */} {isExpanded && ( <> -
-
-
+          
+
+
                 {content}
               
- {/* Actions */} + {/* Actions - simplified */}
e.stopPropagation()} > @@ -98,18 +89,12 @@ export function UserMessage({ variant="ghost" size="sm" onClick={handleCopy} - className="h-7 px-2 text-xs" + className="h-5 px-1.5 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity" > {copied ? ( - <> - - {formatMessage({ id: 'cliMonitor.copied' })} - + ) : ( - <> - - {formatMessage({ id: 'cliMonitor.copy' })} - + )} {onViewRaw && ( @@ -117,10 +102,10 @@ export function UserMessage({ variant="ghost" size="sm" onClick={onViewRaw} - className="h-7 px-2 text-xs" + className="h-5 px-1.5 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity" > {formatMessage({ id: 'cliMonitor.rawJson' })} - + )}
diff --git a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx index 92a9dc7f..98d001f1 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx @@ -27,6 +27,9 @@ import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hook // New components for Tab + JSON Cards import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab'; import { OutputLine } from './CliStreamMonitor/components/OutputLine'; +import { JsonCard } from './CliStreamMonitor/components/JsonCard'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; // ========== Types for CLI WebSocket Messages ========== @@ -74,6 +77,73 @@ function formatDuration(ms: number): string { return `${hours}h ${remainingMinutes}m`; } +// ========== Output Line Card Renderer ========== + +/** + * Get border color class for line type + */ +function getBorderColorForType(type: CliOutputLine['type']): string { + const borderColors = { + tool_call: 'border-l-indigo-500', + metadata: 'border-l-slate-400', + system: 'border-l-slate-400', + stdout: 'border-l-teal-500', + stderr: 'border-l-rose-500', + thought: 'border-l-violet-500', + }; + return borderColors[type] || 'border-l-slate-400'; +} + +/** + * Render a single output line as a card + */ +interface OutputLineCardProps { + line: CliOutputLine; + onCopy?: (content: string) => void; +} + +function OutputLineCard({ line, onCopy }: OutputLineCardProps) { + const borderColor = getBorderColorForType(line.type); + const trimmed = line.content.trim(); + + // Check if line is JSON with 'content' field + let contentToRender = trimmed; + let isMarkdown = false; + + try { + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + const parsed = JSON.parse(trimmed); + if ('content' in parsed && typeof parsed.content === 'string') { + contentToRender = parsed.content; + // Check if content looks like markdown + isMarkdown = !!contentToRender.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m); + } + } + } catch { + // Not valid JSON, use original content + // Check if original content looks like markdown + isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m); + } + + return ( +
+
+ {isMarkdown ? ( +
+ + {contentToRender} + +
+ ) : ( +
+ {contentToRender} +
+ )} +
+
+ ); +} + // ========== Component ========== export interface CliStreamMonitorProps { @@ -411,13 +481,17 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
) : (
- {filteredOutput.map((line, index) => ( - navigator.clipboard.writeText(content)} - /> - ))} + {(() => { + // Group output lines by type + const groupedOutput = groupOutputLines(filteredOutput); + return groupedOutput.map((group, groupIndex) => ( + navigator.clipboard.writeText(content)} + /> + )); + })()}
)} diff --git a/ccw/frontend/src/components/shared/Flowchart.tsx b/ccw/frontend/src/components/shared/Flowchart.tsx index bafedf7e..0571deea 100644 --- a/ccw/frontend/src/components/shared/Flowchart.tsx +++ b/ccw/frontend/src/components/shared/Flowchart.tsx @@ -9,6 +9,8 @@ import { MiniMap, Controls, Background, + Handle, + Position, useNodesState, useEdgesState, type Node, @@ -17,6 +19,7 @@ import { type NodeTypes, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import { CheckCircle, Circle, Loader2 } from 'lucide-react'; import type { FlowControl } from '@/lib/api'; // Custom node types @@ -27,39 +30,87 @@ interface FlowchartNodeData extends Record { output?: string; type: 'pre-analysis' | 'implementation' | 'section'; dependsOn?: string[]; + status?: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped'; } +// Status icon component +const StatusIcon: React.FC<{ status?: string; className?: string }> = ({ status, className = 'h-4 w-4' }) => { + switch (status) { + case 'completed': + return ; + case 'in_progress': + return ; + case 'blocked': + return ; + case 'skipped': + return ; + default: + return ; + } +}; + // Custom node component const CustomNode: React.FC<{ data: FlowchartNodeData }> = ({ data }) => { const isPreAnalysis = data.type === 'pre-analysis'; const isSection = data.type === 'section'; + const isCompleted = data.status === 'completed'; + const isInProgress = data.status === 'in_progress'; if (isSection) { return ( -
+
+ {data.label} +
); } + // Color scheme based on status + let nodeColor = isPreAnalysis ? '#f59e0b' : '#3b82f6'; + let bgClass = isPreAnalysis + ? 'bg-amber-50 border-amber-500 dark:bg-amber-950/30' + : 'bg-blue-50 border-blue-500 dark:bg-blue-950/30'; + let stepBgClass = isPreAnalysis ? 'bg-amber-500 text-white' : 'bg-blue-500 text-white'; + + // Override for completed status + if (isCompleted) { + nodeColor = '#22c55e'; // green-500 + bgClass = 'bg-green-50 border-green-500 dark:bg-green-950/30'; + stepBgClass = 'bg-green-500 text-white'; + } else if (isInProgress) { + nodeColor = '#f59e0b'; // amber-500 + bgClass = 'bg-amber-50 border-amber-500 dark:bg-amber-950/30'; + stepBgClass = 'bg-amber-500 text-white'; + } + return (
+ {/* Top handle for incoming edges */} + +
- {data.step} + {isCompleted ? : data.step}
-
{data.label}
+
+ + {data.label} + + {data.status && data.status !== 'pending' && ( + + )} +
{data.description && (
{data.description}
)} @@ -70,6 +121,14 @@ const CustomNode: React.FC<{ data: FlowchartNodeData }> = ({ data }) => { )}
+ + {/* Bottom handle for outgoing edges */} +
); }; @@ -136,6 +195,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { target: nodeId, type: 'smoothstep', animated: false, + style: { stroke: '#f59e0b', strokeWidth: 2 }, + markerEnd: { type: 'arrowclosed' as const, color: '#f59e0b' }, }); } else { initialEdges.push({ @@ -144,6 +205,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { target: nodeId, type: 'smoothstep', animated: false, + style: { stroke: '#f59e0b', strokeWidth: 2 }, + markerEnd: { type: 'arrowclosed' as const, color: '#f59e0b' }, }); } @@ -175,7 +238,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { target: implSectionId, type: 'smoothstep', animated: true, - style: { stroke: 'hsl(var(--primary))' }, + style: { stroke: '#3b82f6', strokeWidth: 2 }, + markerEnd: { type: 'arrowclosed' as const, color: '#3b82f6' }, }); } @@ -186,11 +250,52 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { // Handle both string and ImplementationStep types const isString = typeof step === 'string'; - const label = isString ? step : (step.title || `Step ${step.step}`); - const description = isString ? undefined : step.description; - const stepNumber = isString ? (idx + 1) : step.step; + + // Extract just the number from strings like "Step 1", "step1", etc. + const rawStep = isString ? (idx + 1) : (step.step || idx + 1); + const stepNumber = typeof rawStep === 'string' + ? (rawStep.match(/\d+/)?.[0] || idx + 1) + : rawStep; + + // Try multiple fields for label (matching JS version priority) + // Check for content in various possible field names + let label: string; + let description: string | undefined; + + if (isString) { + label = step; + } else { + // Try title first (JS version uses this), then action, description, phase, or any string value + label = step.title || step.action || step.phase || step.description || ''; + + // If still empty, try to extract any non-empty string from the step object + if (!label) { + const stepKeys = Object.keys(step).filter(k => + k !== 'step' && k !== 'depends_on' && k !== 'modification_points' && k !== 'logic_flow' + ); + for (const key of stepKeys) { + const val = step[key as keyof typeof step]; + if (typeof val === 'string' && val.trim()) { + label = val; + break; + } + } + } + + // Final fallback + if (!label) { + label = `Step ${stepNumber}`; + } + + // Set description if different from label + description = step.description && step.description !== label ? step.description : undefined; + } + const dependsOn = isString ? undefined : step.depends_on?.map((d: number | string) => `impl-${Number(d) - 1}`); + // Extract status from step (may be in 'status' field or other locations) + const stepStatus = isString ? undefined : (step.status as string | undefined); + initialNodes.push({ id: nodeId, type: 'custom', @@ -201,6 +306,7 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { step: stepNumber, type: 'implementation' as const, dependsOn, + status: stepStatus, }, }); @@ -212,6 +318,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { target: nodeId, type: 'smoothstep', animated: false, + style: { stroke: '#3b82f6', strokeWidth: 2 }, + markerEnd: { type: 'arrowclosed' as const, color: '#3b82f6' }, }); } else { // Sequential edge with styled connection @@ -221,7 +329,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { target: nodeId, type: 'smoothstep', animated: false, - style: { stroke: 'hsl(var(--primary))', strokeWidth: 2 }, + style: { stroke: '#3b82f6', strokeWidth: 2 }, + markerEnd: { type: 'arrowclosed' as const, color: '#3b82f6' }, }); } @@ -235,7 +344,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { target: nodeId, type: 'smoothstep', animated: false, - style: { strokeDasharray: '5,5', stroke: 'hsl(var(--warning))' }, + style: { strokeDasharray: '5,5', stroke: '#f59e0b', strokeWidth: 2 }, + markerEnd: { type: 'arrowclosed' as const, color: '#f59e0b' }, }); }); } @@ -300,6 +410,10 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { nodeColor={(node) => { const data = node.data as FlowchartNodeData; if (data.type === 'section') return '#9ca3af'; + // Status-based colors + if (data.status === 'completed') return '#22c55e'; // green-500 + if (data.status === 'in_progress') return '#f59e0b'; // amber-500 + if (data.status === 'blocked') return '#ef4444'; // red-500 if (data.type === 'pre-analysis') return '#f59e0b'; return '#3b82f6'; }} diff --git a/ccw/frontend/src/components/shared/SessionCard.tsx b/ccw/frontend/src/components/shared/SessionCard.tsx index 399d985b..b95e18ff 100644 --- a/ccw/frontend/src/components/shared/SessionCard.tsx +++ b/ccw/frontend/src/components/shared/SessionCard.tsx @@ -23,6 +23,9 @@ import { Eye, Archive, Trash2, + Clock, + CheckCircle2, + AlertCircle, } from 'lucide-react'; import type { SessionMetadata } from '@/types/store'; @@ -175,17 +178,12 @@ export function SessionCard({ onClick={handleCardClick} > - {/* Header */} -
+ {/* Header - Session ID as title */} +
-

- {session.title || session.session_id} +

+ {session.session_id}

- {session.title && session.title !== session.session_id && ( -

- {session.session_id} -

- )}
{statusLabel} @@ -231,8 +229,15 @@ export function SessionCard({
- {/* Meta info */} -
+ {/* Title as description */} + {session.title && ( +

+ {session.title} +

+ )} + + {/* Meta info - enriched */} +
{formatDate(session.created_at)} @@ -241,6 +246,18 @@ export function SessionCard({ {progress.total} {formatMessage({ id: 'sessions.card.tasks' })} + {progress.total > 0 && ( + + + {progress.completed} {formatMessage({ id: 'sessions.card.completed' })} + + )} + {session.updated_at && session.updated_at !== session.created_at && ( + + + {formatMessage({ id: 'sessions.card.updated' })}: {formatDate(session.updated_at)} + + )}
{/* Progress bar (only show if not planning and has tasks) */} @@ -254,16 +271,19 @@ export function SessionCard({
)} - {/* Description (if exists) */} - {session.description && ( -

+ {/* Description (if exists and different from title) */} + {session.description && session.description !== session.title && ( +

{session.description}

)} diff --git a/ccw/frontend/src/components/shared/TaskDrawer.tsx b/ccw/frontend/src/components/shared/TaskDrawer.tsx index 94c6e6cc..a71123b6 100644 --- a/ccw/frontend/src/components/shared/TaskDrawer.tsx +++ b/ccw/frontend/src/components/shared/TaskDrawer.tsx @@ -150,7 +150,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
- {taskId} + {taskId} {formatMessage({ id: statusConfig.label })} @@ -188,13 +188,14 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) { {/* Tab Content (scrollable) */}
- {/* Overview Tab */} + {/* Overview Tab - Rich display matching JS version */} -
- {/* Description */} +
+ {/* Description Section */} {taskDescription && ( -
-

+
+

+ 📝 {formatMessage({ id: 'sessionDetail.taskDrawer.overview.description' })}

@@ -203,30 +204,94 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {

)} + {/* Scope Section */} + {(task as LiteTask).meta?.scope && ( +
+

+ 📁 + Scope +

+
+ {(task as LiteTask).meta?.scope} +
+
+ )} + + {/* Acceptance Criteria Section */} + {(task as LiteTask).context?.acceptance && (task as LiteTask).context!.acceptance!.length > 0 && ( +
+

+ + {formatMessage({ id: 'liteTasks.acceptanceCriteria' })} +

+
+ {(task as LiteTask).context!.acceptance!.map((criterion, i) => ( +
+ + {criterion} +
+ ))} +
+
+ )} + + {/* Focus Paths / Reference Section */} + {(task as LiteTask).context?.focus_paths && (task as LiteTask).context!.focus_paths!.length > 0 && ( +
+

+ 📚 + {formatMessage({ id: 'liteTasks.focusPaths' })} +

+
+ {(task as LiteTask).context!.focus_paths!.map((path, i) => ( + + {path} + + ))} +
+
+ )} + + {/* Dependencies Section */} + {(task as LiteTask).context?.depends_on && (task as LiteTask).context!.depends_on!.length > 0 && ( +
+

+ 🔗 + {formatMessage({ id: 'liteTasks.dependsOn' })} +

+
+ {(task as LiteTask).context!.depends_on!.map((dep, i) => ( + {dep} + ))} +
+
+ )} + {/* Pre-analysis Steps */} {flowControl?.pre_analysis && flowControl.pre_analysis.length > 0 && ( -
-

+
+

+ 🔍 {formatMessage({ id: 'sessionDetail.taskDrawer.overview.preAnalysis' })}

{flowControl.pre_analysis.map((step, index) => ( -
-
- - {index + 1} - -
-

{step.step}

+
+ + {index + 1} + +
+

{step.step || step.action}

+ {step.action && step.action !== step.step && (

{step.action}

- {step.commands && step.commands.length > 0 && ( -
- - {step.commands.join('; ')} - -
- )} -
+ )} + {step.commands && step.commands.length > 0 && ( +
+ {step.commands.map((cmd, i) => ( + {cmd} + ))} +
+ )}
))} @@ -236,41 +301,78 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) { {/* Implementation Steps */} {flowControl?.implementation_approach && flowControl.implementation_approach.length > 0 && ( -
-

+
+

+ 📋 {formatMessage({ id: 'sessionDetail.taskDrawer.overview.implementationSteps' })}

-
+
    {flowControl.implementation_approach.map((step, index) => { const isString = typeof step === 'string'; - const title = isString ? step : (step.title || `Step ${step.step || index + 1}`); - const description = isString ? undefined : step.description; - const stepNumber = isString ? (index + 1) : (step.step || index + 1); + // Extract just the number from strings like "Step 1", "step1", etc. + const rawStep = isString ? (index + 1) : (step.step || index + 1); + const stepNumber = typeof rawStep === 'string' + ? (rawStep.match(/\d+/)?.[0] || index + 1) + : rawStep; + + // Try multiple fields for title (matching JS version) + let stepTitle: string; + let stepDesc: string | undefined; + + if (isString) { + stepTitle = step; + } else { + // Try title first, then action, phase, description + stepTitle = step.title || step.action || step.phase || ''; + + // If empty, try any string value from the object + if (!stepTitle) { + const stepKeys = Object.keys(step).filter(k => + k !== 'step' && k !== 'depends_on' && k !== 'modification_points' && k !== 'logic_flow' + ); + for (const key of stepKeys) { + const val = step[key as keyof typeof step]; + if (typeof val === 'string' && val.trim()) { + stepTitle = val; + break; + } + } + } + + // Final fallback + if (!stepTitle) { + stepTitle = `Step ${stepNumber}`; + } + + // Description if different from title + stepDesc = step.description && step.description !== stepTitle ? step.description : undefined; + } return ( -
    -
    - - {stepNumber} - -
    -

    {title}

    - {description && ( -

    {description}

    - )} -
    +
  1. + + {stepNumber} + +
    +

    {stepTitle}

    + {stepDesc && ( +

    {stepDesc}

    + )}
    -
  2. + ); })} -
    +
)} {/* Empty State */} {!taskDescription && - (!flowControl?.pre_analysis || flowControl.pre_analysis.length === 0) && - (!flowControl?.implementation_approach || flowControl.implementation_approach.length === 0) && ( + !(task as LiteTask).meta?.scope && + !((task as LiteTask).context?.acceptance?.length) && + !((task as LiteTask).context?.focus_paths?.length) && + !(flowControl?.pre_analysis?.length) && + !(flowControl?.implementation_approach?.length) && (

diff --git a/ccw/frontend/src/components/ui/Badge.tsx b/ccw/frontend/src/components/ui/Badge.tsx index e74d67d4..e61bf02d 100644 --- a/ccw/frontend/src/components/ui/Badge.tsx +++ b/ccw/frontend/src/components/ui/Badge.tsx @@ -33,10 +33,13 @@ export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -

- ); -} +const Badge = React.forwardRef( + ({ className, variant, ...props }, ref) => { + return ( +
+ ); + } +); +Badge.displayName = "Badge"; export { Badge, badgeVariants }; diff --git a/ccw/frontend/src/components/ui/index.ts b/ccw/frontend/src/components/ui/index.ts index 49a688d6..4e0440db 100644 --- a/ccw/frontend/src/components/ui/index.ts +++ b/ccw/frontend/src/components/ui/index.ts @@ -95,3 +95,18 @@ export { CollapsibleTrigger, CollapsibleContent, } from "./Collapsible"; + +// AlertDialog (Radix) +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} from "./AlertDialog"; diff --git a/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx b/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx index daaff0b0..8f2f4756 100644 --- a/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx +++ b/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx @@ -1,10 +1,10 @@ // ======================================== // Workspace Selector Component // ======================================== -// Dropdown for selecting recent workspaces with manual path input dialog +// Dropdown for selecting recent workspaces with folder browser and manual path input -import { useState, useCallback } from 'react'; -import { ChevronDown, X } from 'lucide-react'; +import { useState, useCallback, useRef } from 'react'; +import { ChevronDown, X, FolderOpen, Check } from 'lucide-react'; import { useIntl } from 'react-intl'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; @@ -89,6 +89,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) { const [isBrowseOpen, setIsBrowseOpen] = useState(false); const [manualPath, setManualPath] = useState(''); + // Hidden file input for folder selection + const folderInputRef = useRef(null); + /** * Handle path selection from dropdown */ @@ -112,33 +115,40 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) { ); /** - * Handle open browse dialog - tries file dialog first, falls back to manual input + * Handle open folder browser - trigger hidden file input click */ - const handleBrowseFolder = useCallback(async () => { + const handleBrowseFolder = useCallback(() => { setIsDropdownOpen(false); - - // Try to use Electron/Electron-Tauri file dialog API if available - if ((window as any).electronAPI?.showOpenDialog) { - try { - const result = await (window as any).electronAPI.showOpenDialog({ - properties: ['openDirectory'], - }); - - if (result && result.filePaths && result.filePaths.length > 0) { - const selectedPath = result.filePaths[0]; - await switchWorkspace(selectedPath); - return; - } - } catch (error) { - console.error('Failed to open folder dialog:', error); - // Fall through to manual input dialog - } - } - - // Fallback: open manual path input dialog - setIsBrowseOpen(true); + // Trigger the hidden file input click + folderInputRef.current?.click(); }, []); + /** + * Handle folder selection from file input + */ + const handleFolderSelect = useCallback( + async (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + // Get the path from the first file + const firstFile = files[0]; + // The webkitRelativePath contains the full path relative to the selected folder + // We need to get the parent directory path + const relativePath = firstFile.webkitRelativePath; + const folderPath = relativePath.substring(0, relativePath.indexOf('/')); + + // In browser environment, we can't get the full absolute path + // We need to ask the user to confirm or use the folder name + // For now, open the manual dialog with the folder name as hint + setManualPath(folderPath); + setIsBrowseOpen(true); + } + // Reset input value to allow selecting the same folder again + e.target.value = ''; + }, + [] + ); + /** * Handle manual path submission */ @@ -214,18 +224,23 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) { key={path} onClick={() => handleSelectPath(path)} className={cn( - 'flex items-center gap-2 cursor-pointer group', - isCurrent && 'bg-accent' + 'flex items-center gap-2 cursor-pointer group/path-item pr-8', + isCurrent && 'bg-accent/50' )} title={path} > - {truncatedItemPath} + + {truncatedItemPath} + {/* Delete button for non-current paths */} {!isCurrent && ( )} + {/* Check icon for current workspace */} {isCurrent && ( - - {formatMessage({ id: 'workspace.selector.current' })} - + )} ); @@ -245,16 +259,49 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) { {recentPaths.length > 0 && } - {/* Browse button to open manual path dialog */} + {/* Browse button to open folder selector */} - {formatMessage({ id: 'workspace.selector.browse' })} + +
+
+ {formatMessage({ id: 'workspace.selector.browse' })} +
+
+ {formatMessage({ id: 'workspace.selector.browseHint' })} +
+
+
+ + {/* Manual path input option */} + { + setIsDropdownOpen(false); + setIsBrowseOpen(true); + }} + className="cursor-pointer gap-2" + > + + {formatMessage({ id: 'workspace.selector.manualPath' })} + + {/* Hidden file input for folder selection */} + + {/* Manual path input dialog */} diff --git a/ccw/frontend/src/hooks/index.ts b/ccw/frontend/src/hooks/index.ts index cbcc966a..f9b5fe24 100644 --- a/ccw/frontend/src/hooks/index.ts +++ b/ccw/frontend/src/hooks/index.ts @@ -104,12 +104,14 @@ export type { export { useCommands, useCommandSearch, + useCommandMutations, commandsKeys, } from './useCommands'; export type { CommandsFilter, UseCommandsOptions, UseCommandsReturn, + UseCommandMutationsReturn, } from './useCommands'; // ========== Memory ========== diff --git a/ccw/frontend/src/hooks/useCommands.ts b/ccw/frontend/src/hooks/useCommands.ts index c85f0a59..1fcd047a 100644 --- a/ccw/frontend/src/hooks/useCommands.ts +++ b/ccw/frontend/src/hooks/useCommands.ts @@ -3,9 +3,11 @@ // ======================================== // TanStack Query hooks for commands management -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; import { fetchCommands, + toggleCommand as toggleCommandApi, + toggleCommandGroup as toggleCommandGroupApi, type Command, } from '../lib/api'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; @@ -24,6 +26,9 @@ export interface CommandsFilter { search?: string; category?: string; source?: Command['source']; + group?: string; + location?: 'project' | 'user'; + showDisabled?: boolean; } export interface UseCommandsOptions { @@ -36,7 +41,11 @@ export interface UseCommandsReturn { commands: Command[]; categories: string[]; commandsByCategory: Record; + groupedCommands: Record; + groups: string[]; totalCount: number; + enabledCount: number; + disabledCount: number; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -47,6 +56,40 @@ export interface UseCommandsReturn { /** * Hook for fetching and filtering commands */ + +export interface UseCommandMutationsReturn { + toggleCommand: (name: string, enabled: boolean, location: 'project' | 'user') => Promise; + toggleGroup: (groupName: string, enable: boolean, location: 'project' | 'user') => Promise; + isToggling: boolean; +} + +export function useCommandMutations(): UseCommandMutationsReturn { + const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + + const toggleMutation = useMutation({ + mutationFn: ({ name, enabled, location }: { name: string; enabled: boolean; location: 'project' | 'user' }) => + toggleCommandApi(name, enabled, location, projectPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: commandsKeys.all }); + }, + }); + + const toggleGroupMutation = useMutation({ + mutationFn: ({ groupName, enable, location }: { groupName: string; enable: boolean; location: 'project' | 'user' }) => + toggleCommandGroupApi(groupName, enable, location, projectPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: commandsKeys.all }); + }, + }); + + return { + toggleCommand: (name, enabled, location) => toggleMutation.mutateAsync({ name, enabled, location }), + toggleGroup: (groupName, enable, location) => toggleGroupMutation.mutateAsync({ groupName, enable, location }), + isToggling: toggleMutation.isPending || toggleGroupMutation.isPending, + }; +} + export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn { const { filter, staleTime = STALE_TIME, enabled = true } = options; const queryClient = useQueryClient(); @@ -85,6 +128,18 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn commands = commands.filter((c) => c.source === filter.source); } + if (filter?.group) { + commands = commands.filter((c) => c.group === filter.group); + } + + if (filter?.location) { + commands = commands.filter((c) => c.location === filter.location); + } + + if (filter?.showDisabled === false) { + commands = commands.filter((c) => c.enabled !== false); + } + return commands; })(); @@ -101,6 +156,21 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn commandsByCategory[category].push(command); } + // Group by group + const groupedCommands: Record = {}; + const groups = new Set(); + const enabledCount = allCommands.filter(c => c.enabled !== false).length; + const disabledCount = allCommands.length - enabledCount; + + for (const command of allCommands) { + const group = command.group || 'other'; + groups.add(group); + if (!groupedCommands[group]) { + groupedCommands[group] = []; + } + groupedCommands[group].push(command); + } + const refetch = async () => { await query.refetch(); }; @@ -113,6 +183,10 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn commands: filteredCommands, categories: Array.from(categories).sort(), commandsByCategory, + groupedCommands, + groups: Array.from(groups).sort(), + enabledCount, + disabledCount, totalCount: allCommands.length, isLoading: query.isLoading, isFetching: query.isFetching, diff --git a/ccw/frontend/src/hooks/useSessionDetail.ts b/ccw/frontend/src/hooks/useSessionDetail.ts index 4d2bb518..b484b700 100644 --- a/ccw/frontend/src/hooks/useSessionDetail.ts +++ b/ccw/frontend/src/hooks/useSessionDetail.ts @@ -7,10 +7,10 @@ import { useQuery } from '@tanstack/react-query'; import { fetchSessionDetail } from '../lib/api'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; -// Query key factory +// Query key factory - include projectPath as part of the key export const sessionDetailKeys = { all: ['sessionDetail'] as const, - detail: (id: string) => [...sessionDetailKeys.all, 'detail', id] as const, + detail: (id: string, projectPath: string) => [...sessionDetailKeys.all, 'detail', id, projectPath] as const, }; // Default stale time: 30 seconds @@ -38,7 +38,7 @@ export function useSessionDetail(sessionId: string, options: UseSessionDetailOpt const queryEnabled = enabled && !!sessionId && !!projectPath; const query = useQuery({ - queryKey: sessionDetailKeys.detail(sessionId), + queryKey: sessionDetailKeys.detail(sessionId, projectPath), queryFn: () => fetchSessionDetail(sessionId, projectPath), staleTime, enabled: queryEnabled, diff --git a/ccw/frontend/src/hooks/useSkills.ts b/ccw/frontend/src/hooks/useSkills.ts index 0874cf3c..d7b95107 100644 --- a/ccw/frontend/src/hooks/useSkills.ts +++ b/ccw/frontend/src/hooks/useSkills.ts @@ -6,7 +6,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { fetchSkills, - toggleSkill, + enableSkill, + disableSkill, type Skill, type SkillsResponse, } from '../lib/api'; @@ -28,6 +29,7 @@ export interface SkillsFilter { category?: string; source?: Skill['source']; enabledOnly?: boolean; + location?: 'project' | 'user'; } export interface UseSkillsOptions { @@ -43,6 +45,8 @@ export interface UseSkillsReturn { skillsByCategory: Record; totalCount: number; enabledCount: number; + projectSkills: Skill[]; + userSkills: Skill[]; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -68,10 +72,18 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn { const allSkills = query.data?.skills ?? []; + // Separate by location + const projectSkills = allSkills.filter(s => s.location === 'project'); + const userSkills = allSkills.filter(s => s.location === 'user'); + // Apply filters const filteredSkills = (() => { let skills = allSkills; + if (filter?.location) { + skills = skills.filter((s) => s.location === filter.location); + } + if (filter?.search) { const searchLower = filter.search.toLowerCase(); skills = skills.filter( @@ -129,6 +141,8 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn { skillsByCategory, totalCount: allSkills.length, enabledCount: enabledSkills.length, + projectSkills, + userSkills, isLoading: query.isLoading, isFetching: query.isFetching, error: query.error, @@ -140,7 +154,7 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn { // ========== Mutations ========== export interface UseToggleSkillReturn { - toggleSkill: (skillName: string, enabled: boolean) => Promise; + toggleSkill: (skillName: string, enabled: boolean, location: 'project' | 'user') => Promise; isToggling: boolean; error: Error | null; } @@ -150,8 +164,10 @@ export function useToggleSkill(): UseToggleSkillReturn { const projectPath = useWorkflowStore(selectProjectPath); const mutation = useMutation({ - mutationFn: ({ skillName, enabled }: { skillName: string; enabled: boolean }) => - toggleSkill(skillName, enabled), + mutationFn: ({ skillName, enabled, location }: { skillName: string; enabled: boolean; location: 'project' | 'user' }) => + enabled + ? enableSkill(skillName, location, projectPath) + : disableSkill(skillName, location, projectPath), onSuccess: () => { // Invalidate to ensure sync with server queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.skills(projectPath) : ['skills'] }); @@ -159,7 +175,7 @@ export function useToggleSkill(): UseToggleSkillReturn { }); return { - toggleSkill: (skillName, enabled) => mutation.mutateAsync({ skillName, enabled }), + toggleSkill: (skillName, enabled, location) => mutation.mutateAsync({ skillName, enabled, location }), isToggling: mutation.isPending, error: mutation.error, }; diff --git a/ccw/frontend/src/index.css b/ccw/frontend/src/index.css index 8e5f552f..10807950 100644 --- a/ccw/frontend/src/index.css +++ b/ccw/frontend/src/index.css @@ -18,7 +18,7 @@ --border: 220 20% 88%; --text: 220 30% 15%; --text-secondary: 220 15% 45%; - --accent: 220 90% 56%; + --accent: 220 60% 65%; /* Legacy variables for backward compatibility */ --background: var(--bg); @@ -27,7 +27,7 @@ --card-foreground: var(--text); --primary: var(--accent); --primary-foreground: 0 0% 100%; - --primary-light: 220 90% 95%; + --primary-light: 220 60% 92%; --secondary: 220 60% 65%; --secondary-foreground: 0 0% 100%; --accent-foreground: 0 0% 100%; @@ -44,7 +44,7 @@ --success-light: 142 76% 90%; --warning: 38 92% 50%; --warning-light: 48 96% 89%; - --info: 210 80% 55%; + --info: 220 60% 60%; --info-light: 210 80% 92%; --indigo: 239 65% 60%; --indigo-light: 239 65% 92%; @@ -59,7 +59,7 @@ --border: 220 20% 22%; --text: 220 20% 90%; --text-secondary: 220 15% 60%; - --accent: 220 90% 60%; + --accent: 220 60% 65%; /* Legacy variables */ --background: var(--bg); @@ -68,7 +68,7 @@ --card-foreground: var(--text); --primary: var(--accent); --primary-foreground: 220 30% 10%; - --primary-light: 220 70% 25%; + --primary-light: 220 60% 30%; --secondary: 220 60% 60%; --secondary-foreground: 0 0% 100%; --accent-foreground: 220 30% 10%; @@ -85,7 +85,7 @@ --success-light: 142 50% 20%; --warning: 38 85% 45%; --warning-light: 40 50% 20%; - --info: 210 75% 50%; + --info: 220 60% 55%; --info-light: 210 50% 20%; --indigo: 239 60% 55%; --indigo-light: 239 40% 20%; @@ -346,7 +346,7 @@ --border: var(--border, 220 20% 22%); --text: var(--text, 220 20% 90%); --text-secondary: var(--text-secondary, 220 15% 60%); - --accent: var(--accent, 220 90% 60%); + --accent: var(--accent, 220 60% 65%); /* Apply dark-blue theme as fallback */ --background: 220 30% 10%; @@ -355,13 +355,13 @@ --card-foreground: 220 20% 90%; --border: 220 20% 22%; --input: 220 20% 22%; - --ring: 220 90% 60%; - --primary: 220 90% 60%; + --ring: 220 60% 65%; + --primary: 220 60% 65%; --primary-foreground: 220 30% 10%; - --primary-light: 220 70% 25%; + --primary-light: 220 60% 30%; --secondary: 220 60% 60%; --secondary-foreground: 0 0% 100%; - --accent: 220 90% 60%; + --accent: 220 60% 65%; --accent-foreground: 220 30% 10%; --destructive: 8 70% 50%; --destructive-foreground: 0 0% 100%; @@ -374,7 +374,7 @@ --success-light: 142 50% 20%; --warning: 38 85% 45%; --warning-light: 40 50% 20%; - --info: 210 75% 50%; + --info: 220 60% 55%; --info-light: 210 50% 20%; --indigo: 239 60% 55%; --indigo-light: 239 40% 20%; diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 8459f83e..abbf43bb 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -892,6 +892,7 @@ export interface Skill { source?: 'builtin' | 'custom' | 'community'; version?: string; author?: string; + location?: 'project' | 'user'; } export interface SkillsResponse { @@ -903,12 +904,18 @@ export interface SkillsResponse { * @param projectPath - Optional project path to filter data by workspace */ export async function fetchSkills(projectPath?: string): Promise { + // Helper to add location to skills + const addLocation = (skills: Skill[], location: 'project' | 'user'): Skill[] => + skills.map(skill => ({ ...skill, location })); + // Try with project path first, fall back to global on 403/404 if (projectPath) { try { const url = `/api/skills?path=${encodeURIComponent(projectPath)}`; const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url); - const allSkills = [...(data.projectSkills ?? []), ...(data.userSkills ?? [])]; + const projectSkillsWithLocation = addLocation(data.projectSkills ?? [], 'project'); + const userSkillsWithLocation = addLocation(data.userSkills ?? [], 'user'); + const allSkills = [...projectSkillsWithLocation, ...userSkillsWithLocation]; return { skills: data.skills ?? allSkills, }; @@ -924,19 +931,39 @@ export async function fetchSkills(projectPath?: string): Promise } // Fallback: fetch global skills const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/skills'); - const allSkills = [...(data.projectSkills ?? []), ...(data.userSkills ?? [])]; + const projectSkillsWithLocation = addLocation(data.projectSkills ?? [], 'project'); + const userSkillsWithLocation = addLocation(data.userSkills ?? [], 'user'); + const allSkills = [...projectSkillsWithLocation, ...userSkillsWithLocation]; return { skills: data.skills ?? allSkills, }; } /** - * Toggle skill enabled status + * Enable a skill */ -export async function toggleSkill(skillName: string, enabled: boolean): Promise { - return fetchApi(`/api/skills/${encodeURIComponent(skillName)}`, { - method: 'PATCH', - body: JSON.stringify({ enabled }), +export async function enableSkill( + skillName: string, + location: 'project' | 'user', + projectPath?: string +): Promise { + return fetchApi(`/api/skills/${encodeURIComponent(skillName)}/enable`, { + method: 'POST', + body: JSON.stringify({ location, projectPath }), + }); +} + +/** + * Disable a skill + */ +export async function disableSkill( + skillName: string, + location: 'project' | 'user', + projectPath?: string +): Promise { + return fetchApi(`/api/skills/${encodeURIComponent(skillName)}/disable`, { + method: 'POST', + body: JSON.stringify({ location, projectPath }), }); } @@ -950,10 +977,18 @@ export interface Command { category?: string; aliases?: string[]; source?: 'builtin' | 'custom'; + group?: string; + enabled?: boolean; + location?: 'project' | 'user'; + path?: string; + relativePath?: string; } export interface CommandsResponse { commands: Command[]; + groups?: string[]; + projectGroupsConfig?: Record; + userGroupsConfig?: Record; } /** @@ -965,10 +1000,20 @@ export async function fetchCommands(projectPath?: string): Promise(url); + const data = await fetchApi<{ + commands?: Command[]; + projectCommands?: Command[]; + userCommands?: Command[]; + groups?: string[]; + projectGroupsConfig?: Record; + userGroupsConfig?: Record; + }>(url); const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])]; return { commands: data.commands ?? allCommands, + groups: data.groups, + projectGroupsConfig: data.projectGroupsConfig, + userGroupsConfig: data.userGroupsConfig, }; } catch (error: unknown) { const apiError = error as ApiError; @@ -981,13 +1026,65 @@ export async function fetchCommands(projectPath?: string): Promise('/api/commands'); + const data = await fetchApi<{ + commands?: Command[]; + projectCommands?: Command[]; + userCommands?: Command[]; + groups?: string[]; + projectGroupsConfig?: Record; + userGroupsConfig?: Record; + }>('/api/commands'); const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])]; return { commands: data.commands ?? allCommands, + groups: data.groups, + projectGroupsConfig: data.projectGroupsConfig, + userGroupsConfig: data.userGroupsConfig, }; } +/** + * Toggle command enabled status + */ +export async function toggleCommand( + commandName: string, + enabled: boolean, + location: 'project' | 'user', + projectPath?: string +): Promise<{ success: boolean; message: string }> { + return fetchApi<{ success: boolean; message: string }>(`/api/commands/${encodeURIComponent(commandName)}/toggle`, { + method: 'POST', + body: JSON.stringify({ enabled, location, projectPath }), + }); +} + +/** + * Toggle all commands in a group + */ +export async function toggleCommandGroup( + groupName: string, + enable: boolean, + location: 'project' | 'user', + projectPath?: string +): Promise<{ success: boolean; results: any[]; message: string }> { + return fetchApi<{ success: boolean; results: any[]; message: string }>(`/api/commands/group/${encodeURIComponent(groupName)}/toggle`, { + method: 'POST', + body: JSON.stringify({ enable, location, projectPath }), + }); +} + +/** + * Get commands groups configuration + */ +export async function getCommandsGroupsConfig( + location: 'project' | 'user', + projectPath?: string +): Promise<{ groups: Record; assignments: Record }> { + const params = new URLSearchParams({ location }); + if (projectPath) params.set('path', projectPath); + return fetchApi<{ groups: Record; assignments: Record }>(`/api/commands/groups/config?${params}`); +} + // ========== Memory API ========== export interface CoreMemory { @@ -1286,10 +1383,9 @@ export async function fetchSessionDetail(sessionId: string, projectPath?: string } // Step 2: Use the session path to fetch detail data from the correct endpoint - // Backend expects path parameter, not sessionId + // Backend expects the actual session directory path, not the project path const sessionPath = (session as any).path || session.session_id; - const pathParam = projectPath || sessionPath; - const detailData = await fetchApi(`/api/session-detail?path=${encodeURIComponent(pathParam)}&type=all`); + const detailData = await fetchApi(`/api/session-detail?path=${encodeURIComponent(sessionPath)}&type=all`); // Step 3: Transform the response to match SessionDetailResponse interface // Also check for summaries array and extract first one if summary is empty @@ -1298,13 +1394,17 @@ export async function fetchSessionDetail(sessionId: string, projectPath?: string finalSummary = detailData.summaries[0].content || detailData.summaries[0].name || ''; } + // Step 4: Transform context to match SessionDetailContext interface + // Backend returns raw context-package.json content, frontend expects it nested under 'context' field + const transformedContext = detailData.context ? { context: detailData.context } : undefined; + return { session, - context: detailData.context, + context: transformedContext, summary: finalSummary, summaries: detailData.summaries, implPlan: detailData.implPlan, - conflicts: detailData.conflicts, + conflicts: detailData.conflictResolution, // Backend returns 'conflictResolution', not 'conflicts' review: detailData.review, }; } @@ -1504,6 +1604,7 @@ export interface ImplementationStep { commands?: string[]; steps?: string[]; test_patterns?: string; + status?: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped'; [key: string]: unknown; } @@ -1548,12 +1649,18 @@ export interface LiteTaskSession { type: 'lite-plan' | 'lite-fix' | 'multi-cli-plan'; title?: string; description?: string; + path?: string; tasks?: LiteTask[]; metadata?: Record; latestSynthesis?: { title?: string | { en?: string; zh?: string }; status?: string; }; + diagnoses?: { + manifest?: Record; + items?: Array>; + }; + plan?: Record; roundCount?: number; status?: string; createdAt?: string; @@ -1594,6 +1701,31 @@ export async function fetchLiteTaskSession( return sessions.find(s => s.id === sessionId || s.session_id === sessionId) || null; } +/** + * Fetch context data for a lite task session + * Uses the session-detail API with type=context + */ +export interface LiteSessionContext { + context?: Record; + explorations?: { + manifest?: Record; + data?: Record; + }; + diagnoses?: { + manifest?: Record; + items?: Array>; + }; +} + +export async function fetchLiteSessionContext( + sessionPath: string +): Promise { + const data = await fetchApi( + `/api/session-detail?path=${encodeURIComponent(sessionPath)}&type=context` + ); + return data; +} + // ========== Review Session API ========== export interface ReviewFinding { diff --git a/ccw/frontend/src/lib/theme.ts b/ccw/frontend/src/lib/theme.ts index 05bdf322..b02d3ee7 100644 --- a/ccw/frontend/src/lib/theme.ts +++ b/ccw/frontend/src/lib/theme.ts @@ -28,7 +28,7 @@ export const COLOR_SCHEMES: ThemeOption[] = [ { id: 'blue', name: '经典蓝', - accentColor: '#3b82f6', // blue-500 + accentColor: '#5b8fc4', // blue-gray tone description: 'Classic professional blue tone' }, { diff --git a/ccw/frontend/src/locales/en/cli-hooks.json b/ccw/frontend/src/locales/en/cli-hooks.json index e16c6759..9bf9e27c 100644 --- a/ccw/frontend/src/locales/en/cli-hooks.json +++ b/ccw/frontend/src/locales/en/cli-hooks.json @@ -3,6 +3,7 @@ "description": "Manage CLI hooks for automated workflows", "allTools": "All tools", "trigger": { + "SessionStart": "Session Start", "UserPromptSubmit": "User Prompt Submit", "PreToolUse": "Pre Tool Use", "PostToolUse": "Post Tool Use", @@ -66,29 +67,13 @@ "automation": "Automation" }, "templates": { - "ccw-status-tracker": { - "name": "CCW Status Tracker", - "description": "Parse CCW status.json and display current/next command" + "session-start-notify": { + "name": "Session Start Notify", + "description": "Notify dashboard when a new workflow session is created" }, - "ccw-notify": { - "name": "CCW Dashboard Notify", - "description": "Send notifications to CCW dashboard when files are written" - }, - "codexlens-update": { - "name": "CodexLens Auto-Update", - "description": "Update CodexLens index when files are written or edited" - }, - "git-add": { - "name": "Auto Git Stage", - "description": "Automatically stage written files to git" - }, - "lint-check": { - "name": "Auto ESLint", - "description": "Run ESLint on JavaScript/TypeScript files after write" - }, - "log-tool": { - "name": "Tool Usage Logger", - "description": "Log all tool executions to a file for audit trail" + "session-state-watch": { + "name": "Session State Watch", + "description": "Watch for session metadata file changes (workflow-session.json)" } }, "actions": { diff --git a/ccw/frontend/src/locales/en/commands.json b/ccw/frontend/src/locales/en/commands.json index e9c144e7..39a998b4 100644 --- a/ccw/frontend/src/locales/en/commands.json +++ b/ccw/frontend/src/locales/en/commands.json @@ -1,6 +1,6 @@ { "title": "Commands Manager", - "description": "Manage custom slash commands for Claude Code", + "description": "Enable/disable CCW commands", "actions": { "create": "New Command", "edit": "Edit Command", @@ -8,18 +8,24 @@ "refresh": "Refresh", "expandAll": "Expand All", "collapseAll": "Collapse All", - "copy": "Copy" + "copy": "Copy", + "showDisabled": "Show Disabled", + "hideDisabled": "Hide Disabled" }, "source": { "builtin": "Built-in", "custom": "Custom" }, + "location": { + "project": "Project", + "user": "Global" + }, "filters": { "allCategories": "All Categories", "allSources": "All Sources", "category": "Category", "source": "Source", - "searchPlaceholder": "Search commands by name, description, or alias..." + "searchPlaceholder": "Search commands by name or description..." }, "card": { "name": "Name", @@ -39,5 +45,17 @@ "description": "Description", "scope": "Scope", "status": "Status" + }, + "stats": { + "total": "Total Commands", + "enabled": "Enabled", + "disabled": "Disabled" + }, + "group": { + "enabled": "enabled", + "clickToEnableAll": "Click to enable all", + "clickToDisableAll": "Click to disable all", + "noCommands": "No commands in this group", + "noEnabledCommands": "No enabled commands in this group" } } diff --git a/ccw/frontend/src/locales/en/lite-tasks.json b/ccw/frontend/src/locales/en/lite-tasks.json index 45da783c..6a501a14 100644 --- a/ccw/frontend/src/locales/en/lite-tasks.json +++ b/ccw/frontend/src/locales/en/lite-tasks.json @@ -11,10 +11,23 @@ "title": "No {type} sessions", "message": "Create a new session to get started." }, + "noResults": { + "title": "No results found", + "message": "Try adjusting your search query." + }, + "searchPlaceholder": "Search sessions or tasks...", + "sortBy": "Sort by", + "sort": { + "date": "Date", + "name": "Name", + "tasks": "Tasks" + }, "flowchart": "Flowchart", "implementationFlow": "Implementation Flow", "focusPaths": "Focus Paths", "acceptanceCriteria": "Acceptance Criteria", + "dependsOn": "Depends On", + "tasksCount": "tasks", "emptyDetail": { "title": "No tasks in this session", "message": "This session does not contain any tasks yet." @@ -24,5 +37,29 @@ "notFound": { "title": "Lite Task Not Found", "message": "The requested lite task session could not be found." + }, + "expandedTabs": { + "tasks": "Tasks", + "context": "Context" + }, + "contextPanel": { + "loading": "Loading context...", + "error": "Failed to load context", + "empty": "No context data available for this session.", + "explorations": "Explorations", + "explorationsCount": "{count} angles", + "contextPackage": "Context Package", + "diagnoses": "Diagnoses", + "diagnosesCount": "{count} items", + "focusPaths": "Focus Paths", + "summary": "Summary", + "complexity": "Complexity", + "taskDescription": "Task Description" + }, + "quickCards": { + "tasks": "Tasks", + "explorations": "Explorations", + "context": "Context", + "diagnoses": "Diagnoses" } } diff --git a/ccw/frontend/src/locales/en/session-detail.json b/ccw/frontend/src/locales/en/session-detail.json index d42c726e..ef84b040 100644 --- a/ccw/frontend/src/locales/en/session-detail.json +++ b/ccw/frontend/src/locales/en/session-detail.json @@ -33,6 +33,32 @@ "skipped": "Skipped" }, "untitled": "Untitled Task", + "deps": "deps", + "steps": "steps", + "files": "files", + "tab": { + "task": "Task", + "context": "Context" + }, + "quickCards": { + "explorations": "Explorations", + "context": "Context Package", + "dependencies": "Dependencies", + "testContext": "Test Context", + "available": "Available" + }, + "implementationFlow": "Implementation Flow", + "targetFiles": "Target Files", + "dependsOn": "Depends on", + "focusPaths": "Focus Paths", + "acceptance": "Acceptance Criteria", + "noContext": "No context information available for this task.", + "priority": { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low" + }, "empty": { "title": "No Tasks Found", "message": "This session has no tasks yet." @@ -107,6 +133,7 @@ "default": "Summary", "title": "Session Summary", "lines": "lines", + "viewFull": "View Full Summary ({count} lines)", "empty": { "title": "No Summary Available", "message": "This session has no summary yet." diff --git a/ccw/frontend/src/locales/en/sessions.json b/ccw/frontend/src/locales/en/sessions.json index 83d74688..35871bb1 100644 --- a/ccw/frontend/src/locales/en/sessions.json +++ b/ccw/frontend/src/locales/en/sessions.json @@ -36,7 +36,9 @@ "dimensions": "dimensions", "progress": "Progress", "createdAt": "Created", - "updatedAt": "Updated" + "updatedAt": "Updated", + "completed": "completed", + "updated": "Updated" }, "detail": { "overview": "Overview", diff --git a/ccw/frontend/src/locales/en/skills.json b/ccw/frontend/src/locales/en/skills.json index e4c0bd03..887bb7de 100644 --- a/ccw/frontend/src/locales/en/skills.json +++ b/ccw/frontend/src/locales/en/skills.json @@ -1,6 +1,17 @@ { "title": "Skills", "description": "Manage and configure skills", + "disabledSkills": { + "title": "Disabled Skills" + }, + "disableConfirm": { + "title": "Disable Skill?", + "message": "Are you sure you want to disable \"{name}\"?" + }, + "location": { + "project": "Project", + "user": "Global" + }, "source": { "builtin": "Built-in", "custom": "Custom", @@ -12,7 +23,9 @@ "enable": "Enable", "disable": "Disable", "toggle": "Toggle", - "install": "Install Skill" + "install": "Install Skill", + "cancel": "Cancel", + "confirmDisable": "Disable" }, "state": { "enabled": "Enabled", diff --git a/ccw/frontend/src/locales/en/workspace.json b/ccw/frontend/src/locales/en/workspace.json index caa304c6..2d8d7c81 100644 --- a/ccw/frontend/src/locales/en/workspace.json +++ b/ccw/frontend/src/locales/en/workspace.json @@ -4,7 +4,9 @@ "recentPaths": "Recent Projects", "noRecentPaths": "No recent projects", "current": "Current", - "browse": "Select Folder...", + "browse": "Browse Folder...", + "browseHint": "Select a folder from your computer", + "manualPath": "Enter Manually...", "removePath": "Remove from recent", "ariaLabel": "Workspace selector", "dialog": { diff --git a/ccw/frontend/src/locales/zh/cli-hooks.json b/ccw/frontend/src/locales/zh/cli-hooks.json index 155f5ed9..73de71aa 100644 --- a/ccw/frontend/src/locales/zh/cli-hooks.json +++ b/ccw/frontend/src/locales/zh/cli-hooks.json @@ -3,6 +3,7 @@ "description": "管理自动化工作流的 CLI 钩子", "allTools": "所有工具", "trigger": { + "SessionStart": "会话开始", "UserPromptSubmit": "用户提交提示", "PreToolUse": "工具使用前", "PostToolUse": "工具使用后", @@ -66,29 +67,13 @@ "automation": "自动化" }, "templates": { - "ccw-status-tracker": { - "name": "CCW 状态追踪器", - "description": "解析 CCW status.json 并显示当前/下一个命令" + "session-start-notify": { + "name": "会话启动通知", + "description": "当新工作流会话创建时通知仪表盘" }, - "ccw-notify": { - "name": "CCW 面板通知", - "description": "当文件被写入时向 CCW 面板发送通知" - }, - "codexlens-update": { - "name": "CodexLens 自动更新", - "description": "当文件被写入或编辑时更新 CodexLens 索引" - }, - "git-add": { - "name": "自动 Git 暂存", - "description": "自动将写入的文件暂存到 git" - }, - "lint-check": { - "name": "自动 ESLint", - "description": "在写入后对 JavaScript/TypeScript 文件运行 ESLint" - }, - "log-tool": { - "name": "工具使用日志", - "description": "将所有工具执行记录到文件以供审计" + "session-state-watch": { + "name": "会话状态监控", + "description": "监控会话元数据文件变更 (workflow-session.json)" } }, "actions": { diff --git a/ccw/frontend/src/locales/zh/commands.json b/ccw/frontend/src/locales/zh/commands.json index 0d4d1a7e..2249df43 100644 --- a/ccw/frontend/src/locales/zh/commands.json +++ b/ccw/frontend/src/locales/zh/commands.json @@ -1,6 +1,6 @@ { "title": "命令管理", - "description": "管理 Claude Code 自定义斜杠命令", + "description": "启用/禁用 CCW 命令", "actions": { "create": "新建命令", "edit": "编辑命令", @@ -8,18 +8,24 @@ "refresh": "刷新", "expandAll": "全部展开", "collapseAll": "全部收起", - "copy": "复制" + "copy": "复制", + "showDisabled": "显示已禁用", + "hideDisabled": "隐藏已禁用" }, "source": { "builtin": "内置", "custom": "自定义" }, + "location": { + "project": "项目", + "user": "全局" + }, "filters": { "allCategories": "所有类别", "allSources": "所有来源", "category": "类别", "source": "来源", - "searchPlaceholder": "按名称、描述或别名搜索命令..." + "searchPlaceholder": "按名称或描述搜索命令..." }, "card": { "name": "名称", @@ -39,5 +45,17 @@ "description": "描述", "scope": "作用域", "status": "状态" + }, + "stats": { + "total": "命令总数", + "enabled": "已启用", + "disabled": "已禁用" + }, + "group": { + "enabled": "已启用", + "clickToEnableAll": "点击全部启用", + "clickToDisableAll": "点击全部禁用", + "noCommands": "此分组中没有命令", + "noEnabledCommands": "此分组中没有已启用的命令" } } diff --git a/ccw/frontend/src/locales/zh/lite-tasks.json b/ccw/frontend/src/locales/zh/lite-tasks.json index d57c437e..c85f93ad 100644 --- a/ccw/frontend/src/locales/zh/lite-tasks.json +++ b/ccw/frontend/src/locales/zh/lite-tasks.json @@ -11,10 +11,23 @@ "title": "没有 {type} 会话", "message": "创建新会话以开始使用。" }, + "noResults": { + "title": "未找到结果", + "message": "请尝试调整搜索条件。" + }, + "searchPlaceholder": "搜索会话或任务...", + "sortBy": "排序", + "sort": { + "date": "日期", + "name": "名称", + "tasks": "任务数" + }, "flowchart": "流程图", "implementationFlow": "实现流程", "focusPaths": "关注路径", "acceptanceCriteria": "验收标准", + "dependsOn": "依赖于", + "tasksCount": "个任务", "emptyDetail": { "title": "此会话中没有任务", "message": "此会话尚不包含任何任务。" @@ -24,5 +37,29 @@ "notFound": { "title": "未找到轻量任务", "message": "无法找到请求的轻量任务会话。" + }, + "expandedTabs": { + "tasks": "任务", + "context": "上下文" + }, + "contextPanel": { + "loading": "加载上下文中...", + "error": "加载上下文失败", + "empty": "此会话暂无上下文数据。", + "explorations": "探索结果", + "explorationsCount": "{count} 个角度", + "contextPackage": "上下文包", + "diagnoses": "诊断", + "diagnosesCount": "{count} 个条目", + "focusPaths": "关注路径", + "summary": "摘要", + "complexity": "复杂度", + "taskDescription": "任务描述" + }, + "quickCards": { + "tasks": "任务", + "explorations": "探索", + "context": "上下文", + "diagnoses": "诊断" } } diff --git a/ccw/frontend/src/locales/zh/session-detail.json b/ccw/frontend/src/locales/zh/session-detail.json index 59be61bf..b4fc69fa 100644 --- a/ccw/frontend/src/locales/zh/session-detail.json +++ b/ccw/frontend/src/locales/zh/session-detail.json @@ -33,6 +33,32 @@ "skipped": "已跳过" }, "untitled": "无标题任务", + "deps": "个依赖", + "steps": "步", + "files": "文件", + "tab": { + "task": "任务", + "context": "上下文" + }, + "quickCards": { + "explorations": "探索", + "context": "上下文包", + "dependencies": "依赖", + "testContext": "测试上下文", + "available": "可用" + }, + "implementationFlow": "实现流程", + "targetFiles": "目标文件", + "dependsOn": "依赖于", + "focusPaths": "关注路径", + "acceptance": "验收标准", + "noContext": "该任务暂无上下文信息。", + "priority": { + "critical": "紧急", + "high": "高", + "medium": "中", + "low": "低" + }, "empty": { "title": "未找到任务", "message": "该会话暂无任务。" @@ -104,7 +130,10 @@ } }, "summary": { + "default": "摘要", "title": "会话摘要", + "lines": "行", + "viewFull": "查看完整摘要({count} 行)", "empty": { "title": "暂无摘要", "message": "该会话暂无摘要。" diff --git a/ccw/frontend/src/locales/zh/sessions.json b/ccw/frontend/src/locales/zh/sessions.json index da496916..e0070f10 100644 --- a/ccw/frontend/src/locales/zh/sessions.json +++ b/ccw/frontend/src/locales/zh/sessions.json @@ -36,7 +36,9 @@ "dimensions": "维度", "progress": "进度", "createdAt": "创建时间", - "updatedAt": "更新时间" + "updatedAt": "更新时间", + "completed": "已完成", + "updated": "更新于" }, "detail": { "overview": "概览", diff --git a/ccw/frontend/src/locales/zh/skills.json b/ccw/frontend/src/locales/zh/skills.json index a2c0bf35..d2ccacca 100644 --- a/ccw/frontend/src/locales/zh/skills.json +++ b/ccw/frontend/src/locales/zh/skills.json @@ -1,6 +1,17 @@ { "title": "技能", "description": "管理和配置技能", + "disabledSkills": { + "title": "已禁用技能" + }, + "disableConfirm": { + "title": "禁用技能?", + "message": "确定要禁用 \"{name}\" 吗?" + }, + "location": { + "project": "项目", + "user": "全局" + }, "source": { "builtin": "内置", "custom": "自定义", @@ -12,7 +23,9 @@ "enable": "启用", "disable": "禁用", "toggle": "切换", - "install": "安装技能" + "install": "安装技能", + "cancel": "取消", + "confirmDisable": "禁用" }, "state": { "enabled": "已启用", diff --git a/ccw/frontend/src/locales/zh/workspace.json b/ccw/frontend/src/locales/zh/workspace.json index edc37f5e..ccd0f117 100644 --- a/ccw/frontend/src/locales/zh/workspace.json +++ b/ccw/frontend/src/locales/zh/workspace.json @@ -4,7 +4,9 @@ "recentPaths": "最近的项目", "noRecentPaths": "没有最近的项目", "current": "当前", - "browse": "选择文件夹...", + "browse": "浏览文件夹...", + "browseHint": "从计算机选择文件夹", + "manualPath": "手动输入...", "removePath": "从最近记录中移除", "ariaLabel": "工作空间选择器", "dialog": { diff --git a/ccw/frontend/src/pages/CommandsManagerPage.tsx b/ccw/frontend/src/pages/CommandsManagerPage.tsx index ab6665d3..d1d60e52 100644 --- a/ccw/frontend/src/pages/CommandsManagerPage.tsx +++ b/ccw/frontend/src/pages/CommandsManagerPage.tsx @@ -10,268 +10,191 @@ import { Search, Plus, RefreshCw, - Copy, - ChevronDown, - ChevronUp, - Code, - BookOpen, - Tag, + Eye, + EyeOff, + CheckCircle2, + XCircle, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { Badge } from '@/components/ui/Badge'; -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select'; -import { useCommands } from '@/hooks'; -import type { Command } from '@/lib/api'; +import { useCommands, useCommandMutations } from '@/hooks'; +import { CommandGroupAccordion } from '@/components/commands/CommandGroupAccordion'; +import { LocationSwitcher } from '@/components/commands/LocationSwitcher'; import { cn } from '@/lib/utils'; -// ========== Command Card Component ========== - -interface CommandCardProps { - command: Command; - isExpanded: boolean; - onToggleExpand: () => void; - onCopy: (text: string) => void; -} - -function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCardProps) { - const { formatMessage } = useIntl(); - - return ( - - {/* Header */} -
-
-
-
- -
-
-
- - /{command.name} - - {command.source && ( - - {command.source} - - )} -
-

- {command.description || formatMessage({ id: 'commands.card.noDescription' })} -

-
-
-
- - {isExpanded ? ( - - ) : ( - - )} -
-
- - {/* Category and Aliases */} -
- {command.category && ( - - - {command.category} - - )} - {command.aliases?.map((alias) => ( - - /{alias} - - ))} -
-
- - {/* Expanded Content */} - {isExpanded && ( -
- {/* Usage */} - {command.usage && ( -
-
- - {formatMessage({ id: 'commands.card.usage' })} -
-
- {command.usage} -
-
- )} - - {/* Examples */} - {command.examples && command.examples.length > 0 && ( -
-
- - {formatMessage({ id: 'commands.card.examples' })} -
-
- {command.examples.map((example, idx) => ( -
- {example} - -
- ))} -
-
- )} -
- )} -
- ); -} - // ========== Main Page Component ========== export function CommandsManagerPage() { const { formatMessage } = useIntl(); + + // Location filter state + const [locationFilter, setLocationFilter] = useState<'project' | 'user'>('project'); + // Show disabled commands state + const [showDisabledCommands, setShowDisabledCommands] = useState(false); + // Expanded groups state (default cli and workflow expanded) + const [expandedGroups, setExpandedGroups] = useState>(new Set(['cli', 'workflow'])); + // Search state const [searchQuery, setSearchQuery] = useState(''); - const [categoryFilter, setCategoryFilter] = useState('all'); - const [sourceFilter, setSourceFilter] = useState('all'); - const [expandedCommands, setExpandedCommands] = useState>(new Set()); const { commands, - categories, - totalCount, + groupedCommands, + groups, + enabledCount, + disabledCount, isLoading, isFetching, refetch, } = useCommands({ filter: { + location: locationFilter, + showDisabled: showDisabledCommands, search: searchQuery || undefined, - category: categoryFilter !== 'all' ? categoryFilter : undefined, - source: sourceFilter !== 'all' ? sourceFilter as Command['source'] : undefined, }, }); - const toggleExpand = (commandName: string) => { - setExpandedCommands((prev) => { + const { toggleCommand, toggleGroup, isToggling } = useCommandMutations(); + + // Toggle group expand/collapse + const toggleGroupExpand = (groupName: string) => { + setExpandedGroups((prev) => { const next = new Set(prev); - if (next.has(commandName)) { - next.delete(commandName); + if (next.has(groupName)) { + next.delete(groupName); } else { - next.add(commandName); + next.add(groupName); } return next; }); }; + // Expand all groups const expandAll = () => { - setExpandedCommands(new Set(commands.map((c) => c.name))); + setExpandedGroups(new Set(groups)); }; + // Collapse all groups const collapseAll = () => { - setExpandedCommands(new Set()); + setExpandedGroups(new Set()); }; - const copyToClipboard = async (text: string) => { + // Toggle individual command + const handleToggleCommand = async (name: string, enabled: boolean) => { try { - await navigator.clipboard.writeText(text); - // TODO: Show toast notification - } catch (err) { - console.error('Failed to copy:', err); + await toggleCommand(name, enabled, locationFilter); + } catch (error) { + console.error('Failed to toggle command:', error); } }; - // Count by source - const builtinCount = useMemo( - () => commands.filter((c) => c.source === 'builtin').length, + // Toggle all commands in a group + const handleToggleGroup = async (groupName: string, enable: boolean) => { + try { + await toggleGroup(groupName, enable, locationFilter); + } catch (error) { + console.error('Failed to toggle group:', error); + } + }; + + // Calculate command counts per location + const projectCount = useMemo( + () => commands.filter((c) => c.location === 'project').length, [commands] ); - const customCount = useMemo( - () => commands.filter((c) => c.source === 'custom').length, + const userCount = useMemo( + () => commands.filter((c) => c.location === 'user').length, [commands] ); return (
{/* Page Header */} -
-
-

- - {formatMessage({ id: 'commands.title' })} -

-

- {formatMessage({ id: 'commands.description' })} -

+
+
+
+

+ + {formatMessage({ id: 'commands.title' })} +

+

+ {formatMessage({ id: 'commands.description' })} +

+
+
+ + +
-
- - + + {/* Location and Show Disabled Controls */} +
+ +
+ +
- {/* Stats Cards */} -
+ {/* Summary Stats */} +
- {totalCount} + {commands.length}
-

{formatMessage({ id: 'common.stats.totalCommands' })}

+

+ {formatMessage({ id: 'commands.stats.total' })} +

- - {builtinCount} + + {enabledCount}
-

{formatMessage({ id: 'commands.source.builtin' })}

+

+ {formatMessage({ id: 'commands.stats.enabled' })} +

- - {customCount} + + {disabledCount}
-

{formatMessage({ id: 'commands.source.custom' })}

-
- -
- - {categories.length} -
-

{formatMessage({ id: 'common.stats.categories' })}

+

+ {formatMessage({ id: 'commands.stats.disabled' })} +

- {/* Filters and Search */} + {/* Search and Expand/Collapse Controls */}
@@ -282,67 +205,51 @@ export function CommandsManagerPage() { className="pl-9" />
-
- - +
+ +
- {/* Expand/Collapse All */} -
- - -
- - {/* Commands List */} + {/* Command Groups Accordion */} {isLoading ? ( -
+
{[1, 2, 3, 4, 5].map((i) => ( -
+
))}
- ) : commands.length === 0 ? ( + ) : groups.length === 0 ? ( -

{formatMessage({ id: 'commands.emptyState.title' })}

+

+ {formatMessage({ id: 'commands.emptyState.title' })} +

{formatMessage({ id: 'commands.emptyState.message' })}

) : ( -
- {commands.map((command) => ( - toggleExpand(command.name)} - onCopy={copyToClipboard} - /> - ))} +
+ {groups.map((groupName) => { + const groupCommands = groupedCommands[groupName] || []; + return ( + + ); + })}
)}
diff --git a/ccw/frontend/src/pages/FixSessionPage.tsx b/ccw/frontend/src/pages/FixSessionPage.tsx index 64b98394..85eae0d6 100644 --- a/ccw/frontend/src/pages/FixSessionPage.tsx +++ b/ccw/frontend/src/pages/FixSessionPage.tsx @@ -289,7 +289,7 @@ export function FixSessionPage() {
- + {task.task_id || task.id || 'N/A'} diff --git a/ccw/frontend/src/pages/HookManagerPage.tsx b/ccw/frontend/src/pages/HookManagerPage.tsx index a800c627..89dc73bd 100644 --- a/ccw/frontend/src/pages/HookManagerPage.tsx +++ b/ccw/frontend/src/pages/HookManagerPage.tsx @@ -10,6 +10,7 @@ import { Plus, Search, RefreshCw, + Play, Zap, Wrench, CheckCircle, @@ -32,6 +33,7 @@ import { cn } from '@/lib/utils'; // ========== Types ========== interface HooksByTrigger { + SessionStart: HookCardData[]; UserPromptSubmit: HookCardData[]; PreToolUse: HookCardData[]; PostToolUse: HookCardData[]; @@ -41,7 +43,7 @@ interface HooksByTrigger { // ========== Helper Functions ========== function isHookTriggerType(value: string): value is HookTriggerType { - return ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'].includes(value); + return ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'].includes(value); } function toHookCardData(hook: { name: string; description?: string; enabled: boolean; trigger: string; matcher?: string; command?: string; script?: string }): HookCardData | null { @@ -60,6 +62,7 @@ function toHookCardData(hook: { name: string; description?: string; enabled: boo function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger { return { + SessionStart: hooks.filter((h) => h.trigger === 'SessionStart'), UserPromptSubmit: hooks.filter((h) => h.trigger === 'UserPromptSubmit'), PreToolUse: hooks.filter((h) => h.trigger === 'PreToolUse'), PostToolUse: hooks.filter((h) => h.trigger === 'PostToolUse'), @@ -69,6 +72,10 @@ function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger { function getTriggerStats(hooksByTrigger: HooksByTrigger) { return { + SessionStart: { + total: hooksByTrigger.SessionStart.length, + enabled: hooksByTrigger.SessionStart.filter((h) => h.enabled).length, + }, UserPromptSubmit: { total: hooksByTrigger.UserPromptSubmit.length, enabled: hooksByTrigger.UserPromptSubmit.filter((h) => h.enabled).length, @@ -215,6 +222,7 @@ export function HookManagerPage() { }; const TRIGGER_TYPES: Array<{ type: HookTriggerType; icon: typeof Zap }> = [ + { type: 'SessionStart', icon: Play }, { type: 'UserPromptSubmit', icon: Zap }, { type: 'PreToolUse', icon: Wrench }, { type: 'PostToolUse', icon: CheckCircle }, diff --git a/ccw/frontend/src/pages/LiteTaskDetailPage.tsx b/ccw/frontend/src/pages/LiteTaskDetailPage.tsx index 79894926..8424a947 100644 --- a/ccw/frontend/src/pages/LiteTaskDetailPage.tsx +++ b/ccw/frontend/src/pages/LiteTaskDetailPage.tsx @@ -393,7 +393,7 @@ export function LiteTaskDetailPage() {
- {taskId} + {taskId} diff --git a/ccw/frontend/src/pages/LiteTasksPage.tsx b/ccw/frontend/src/pages/LiteTasksPage.tsx index ea0f0b07..c684e547 100644 --- a/ccw/frontend/src/pages/LiteTasksPage.tsx +++ b/ccw/frontend/src/pages/LiteTasksPage.tsx @@ -12,13 +12,24 @@ import { FileEdit, MessagesSquare, Calendar, - ListChecks, XCircle, Activity, Repeat, MessageCircle, ChevronDown, ChevronRight, + Search, + SortAsc, + SortDesc, + ListFilter, + Hash, + ListChecks, + Package, + Loader2, + Compass, + Stethoscope, + FolderOpen, + FileText, } from 'lucide-react'; import { useLiteTasks } from '@/hooks/useLiteTasks'; import { Button } from '@/components/ui/Button'; @@ -26,10 +37,12 @@ import { Badge } from '@/components/ui/Badge'; import { Card, CardContent } from '@/components/ui/Card'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'; import { TaskDrawer } from '@/components/shared/TaskDrawer'; -import type { LiteTask, LiteTaskSession } from '@/lib/api'; +import { fetchLiteSessionContext, type LiteTask, type LiteTaskSession, type LiteSessionContext } from '@/lib/api'; import { useNavigate } from 'react-router-dom'; type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan'; +type SortField = 'date' | 'name' | 'tasks'; +type SortOrder = 'asc' | 'desc'; /** * Get i18n text from label object (supports {en, zh} format) @@ -41,6 +54,340 @@ function getI18nText(label: string | { en?: string; zh?: string } | undefined): return label.en || label.zh; } +type ExpandedTab = 'tasks' | 'context'; + +/** + * ExpandedSessionPanel - Multi-tab panel shown when a lite session is expanded + */ +function ExpandedSessionPanel({ + session, + onTaskClick, +}: { + session: LiteTaskSession; + onTaskClick: (task: LiteTask) => void; +}) { + const { formatMessage } = useIntl(); + const [activeTab, setActiveTab] = React.useState('tasks'); + const [contextData, setContextData] = React.useState(null); + const [contextLoading, setContextLoading] = React.useState(false); + const [contextError, setContextError] = React.useState(null); + + const tasks = session.tasks || []; + const taskCount = tasks.length; + + // Load context data lazily when context tab is selected + React.useEffect(() => { + if (activeTab !== 'context') return; + if (contextData || contextLoading) return; + if (!session.path) { + setContextError('No session path available'); + return; + } + + setContextLoading(true); + fetchLiteSessionContext(session.path) + .then((data) => { + setContextData(data); + setContextError(null); + }) + .catch((err) => { + setContextError(err.message || 'Failed to load context'); + }) + .finally(() => { + setContextLoading(false); + }); + }, [activeTab, session.path, contextData, contextLoading]); + + return ( +
+ {/* Quick Info Cards */} +
+ + +
+ + {/* Tasks Tab */} + {activeTab === 'tasks' && ( +
+ {tasks.map((task, index) => ( + { + e.stopPropagation(); + onTaskClick(task); + }} + > + +
+ + {task.task_id || `#${index + 1}`} + +

+ {task.title || formatMessage({ id: 'liteTasks.untitled' })} +

+ {task.status && ( + + {task.status} + + )} +
+ {task.description && ( +

+ {task.description} +

+ )} +
+
+ ))} +
+ )} + + {/* Context Tab */} + {activeTab === 'context' && ( +
+ {contextLoading && ( +
+ + {formatMessage({ id: 'liteTasks.contextPanel.loading' })} +
+ )} + {contextError && !contextLoading && ( +
+ + {formatMessage({ id: 'liteTasks.contextPanel.error' })}: {contextError} +
+ )} + {!contextLoading && !contextError && contextData && ( + + )} + {!contextLoading && !contextError && !contextData && !session.path && ( +
+ +

+ {formatMessage({ id: 'liteTasks.contextPanel.empty' })} +

+
+ )} +
+ )} +
+ ); +} + +/** + * ContextContent - Renders the context data sections + */ +function ContextContent({ + contextData, + session, +}: { + contextData: LiteSessionContext; + session: LiteTaskSession; +}) { + const { formatMessage } = useIntl(); + const plan = session.plan || {}; + const hasExplorations = !!(contextData.explorations?.manifest); + const hasDiagnoses = !!(contextData.diagnoses?.manifest || contextData.diagnoses?.items?.length); + const hasContext = !!contextData.context; + const hasFocusPaths = !!(plan.focus_paths as string[] | undefined)?.length; + const hasSummary = !!(plan.summary as string | undefined); + const hasAnyContent = hasExplorations || hasDiagnoses || hasContext || hasFocusPaths || hasSummary; + + if (!hasAnyContent) { + return ( +
+ +

+ {formatMessage({ id: 'liteTasks.contextPanel.empty' })} +

+
+ ); + } + + return ( +
+ {/* Explorations Section */} + {hasExplorations && ( + } + title={formatMessage({ id: 'liteTasks.contextPanel.explorations' })} + badge={ + contextData.explorations?.manifest?.exploration_count + ? formatMessage( + { id: 'liteTasks.contextPanel.explorationsCount' }, + { count: contextData.explorations.manifest.exploration_count as number } + ) + : undefined + } + > +
+ {!!contextData.explorations?.manifest?.task_description && ( +
+ + {formatMessage({ id: 'liteTasks.contextPanel.taskDescription' })}: + {' '} + {String(contextData.explorations.manifest.task_description)} +
+ )} + {!!contextData.explorations?.manifest?.complexity && ( +
+ + {formatMessage({ id: 'liteTasks.contextPanel.complexity' })}: + {' '} + + {String(contextData.explorations.manifest.complexity)} + +
+ )} + {contextData.explorations?.data && ( +
+ {Object.keys(contextData.explorations.data).map((angle) => ( + + {angle.replace(/-/g, ' ')} + + ))} +
+ )} +
+
+ )} + + {/* Diagnoses Section */} + {hasDiagnoses && ( + } + title={formatMessage({ id: 'liteTasks.contextPanel.diagnoses' })} + badge={ + contextData.diagnoses?.items?.length + ? formatMessage( + { id: 'liteTasks.contextPanel.diagnosesCount' }, + { count: contextData.diagnoses.items.length } + ) + : undefined + } + > + {contextData.diagnoses?.items?.map((item, i) => ( +
+ {(item.title as string) || (item.description as string) || `Diagnosis ${i + 1}`} +
+ ))} +
+ )} + + {/* Context Package Section */} + {hasContext && ( + } + title={formatMessage({ id: 'liteTasks.contextPanel.contextPackage' })} + > +
+            {JSON.stringify(contextData.context, null, 2)}
+          
+
+ )} + + {/* Focus Paths from Plan */} + {hasFocusPaths && ( + } + title={formatMessage({ id: 'liteTasks.contextPanel.focusPaths' })} + > +
+ {(plan.focus_paths as string[]).map((p, i) => ( + + {p} + + ))} +
+
+ )} + + {/* Plan Summary */} + {hasSummary && ( + } + title={formatMessage({ id: 'liteTasks.contextPanel.summary' })} + > +

{plan.summary as string}

+
+ )} +
+ ); +} + +/** + * ContextSection - Collapsible section wrapper for context items + */ +function ContextSection({ + icon, + title, + badge, + children, +}: { + icon: React.ReactNode; + title: string; + badge?: string; + children: React.ReactNode; +}) { + const [isOpen, setIsOpen] = React.useState(true); + + return ( + e.stopPropagation()}> + + {isOpen && ( + + {children} + + )} + + ); +} + /** * LiteTasksPage component - Display lite-plan and lite-fix sessions with expandable tasks */ @@ -51,6 +398,62 @@ export function LiteTasksPage() { const [activeTab, setActiveTab] = React.useState('lite-plan'); const [expandedSessionId, setExpandedSessionId] = React.useState(null); const [selectedTask, setSelectedTask] = React.useState(null); + const [searchQuery, setSearchQuery] = React.useState(''); + const [sortField, setSortField] = React.useState('date'); + const [sortOrder, setSortOrder] = React.useState('desc'); + + // Filter and sort sessions + const filterAndSort = React.useCallback((sessions: LiteTaskSession[]) => { + let filtered = sessions; + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = sessions.filter(session => + session.id.toLowerCase().includes(query) || + session.tasks?.some(task => + task.title?.toLowerCase().includes(query) || + task.task_id?.toLowerCase().includes(query) + ) + ); + } + + // Apply sort + const sorted = [...filtered].sort((a, b) => { + let comparison = 0; + + switch (sortField) { + case 'date': + comparison = new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime(); + break; + case 'name': + comparison = a.id.localeCompare(b.id); + break; + case 'tasks': + comparison = (a.tasks?.length || 0) - (b.tasks?.length || 0); + break; + } + + return sortOrder === 'asc' ? comparison : -comparison; + }); + + return sorted; + }, [searchQuery, sortField, sortOrder]); + + // Filtered data + const filteredLitePlan = React.useMemo(() => filterAndSort(litePlan), [litePlan, filterAndSort]); + const filteredLiteFix = React.useMemo(() => filterAndSort(liteFix), [liteFix, filterAndSort]); + const filteredMultiCliPlan = React.useMemo(() => filterAndSort(multiCliPlan), [multiCliPlan, filterAndSort]); + + // Toggle sort + const toggleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('desc'); + } + }; const handleBack = () => { navigate('/sessions'); @@ -96,7 +499,7 @@ export function LiteTasksPage() { )}
-

{session.id}

+

{session.id}

@@ -104,64 +507,29 @@ export function LiteTasksPage() { {formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
-
+
{session.createdAt && ( {new Date(session.createdAt).toLocaleDateString()} )} - - - {taskCount} {formatMessage({ id: 'session.tasks' })} - + {taskCount > 0 && ( + + + {taskCount} {formatMessage({ id: 'liteTasks.tasksCount' })} + + )}
- {/* Expanded tasks list */} + {/* Expanded tasks panel with tabs */} {isExpanded && session.tasks && session.tasks.length > 0 && ( -
- {session.tasks.map((task, index) => { - const taskStatusColor = task.status === 'completed' ? 'success' : - task.status === 'in_progress' ? 'warning' : - task.status === 'failed' ? 'destructive' : 'secondary'; - - return ( - { - e.stopPropagation(); - setSelectedTask(task); - }} - > - -
-
-
- - {task.task_id || `#${index + 1}`} - - - {task.status} - -
-

- {task.title || formatMessage({ id: 'liteTasks.untitled' })} -

- {task.description && ( -

- {task.description} -

- )} -
-
-
-
- ); - })} -
+ )}
); @@ -195,7 +563,7 @@ export function LiteTasksPage() { )}
-

{session.id}

+

{session.id}

@@ -308,6 +676,58 @@ export function LiteTasksPage() { + {/* Search and Sort Toolbar */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" + /> +
+ + {/* Sort Buttons */} +
+ + + {formatMessage({ id: 'liteTasks.sortBy' })}: + + + + +
+
+ {/* Lite Plan Tab */} {litePlan.length === 0 ? ( @@ -320,8 +740,18 @@ export function LiteTasksPage() { {formatMessage({ id: 'liteTasks.empty.message' })}

+ ) : filteredLitePlan.length === 0 ? ( +
+ +

+ {formatMessage({ id: 'liteTasks.noResults.title' })} +

+

+ {formatMessage({ id: 'liteTasks.noResults.message' })} +

+
) : ( -
{litePlan.map(renderLiteTaskCard)}
+
{filteredLitePlan.map(renderLiteTaskCard)}
)} @@ -337,8 +767,18 @@ export function LiteTasksPage() { {formatMessage({ id: 'liteTasks.empty.message' })}

+ ) : filteredLiteFix.length === 0 ? ( +
+ +

+ {formatMessage({ id: 'liteTasks.noResults.title' })} +

+

+ {formatMessage({ id: 'liteTasks.noResults.message' })} +

+
) : ( -
{liteFix.map(renderLiteTaskCard)}
+
{filteredLiteFix.map(renderLiteTaskCard)}
)} @@ -354,8 +794,18 @@ export function LiteTasksPage() { {formatMessage({ id: 'liteTasks.empty.message' })}

+ ) : filteredMultiCliPlan.length === 0 ? ( +
+ +

+ {formatMessage({ id: 'liteTasks.noResults.title' })} +

+

+ {formatMessage({ id: 'liteTasks.noResults.message' })} +

+
) : ( -
{multiCliPlan.map(renderMultiCliCard)}
+
{filteredMultiCliPlan.map(renderMultiCliCard)}
)} diff --git a/ccw/frontend/src/pages/SessionsPage.tsx b/ccw/frontend/src/pages/SessionsPage.tsx index b39f086f..698db38d 100644 --- a/ccw/frontend/src/pages/SessionsPage.tsx +++ b/ccw/frontend/src/pages/SessionsPage.tsx @@ -7,7 +7,6 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; import { useIntl } from 'react-intl'; import { - Plus, RefreshCw, Search, Filter, @@ -17,7 +16,6 @@ import { } from 'lucide-react'; import { useSessions, - useCreateSession, useArchiveSession, useDeleteSession, type SessionsFilter, @@ -61,15 +59,9 @@ export function SessionsPage() { const [statusFilter, setStatusFilter] = React.useState([]); // Dialog state - const [createDialogOpen, setCreateDialogOpen] = React.useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [sessionToDelete, setSessionToDelete] = React.useState(null); - // Create session form state - const [newSessionId, setNewSessionId] = React.useState(''); - const [newSessionTitle, setNewSessionTitle] = React.useState(''); - const [newSessionDescription, setNewSessionDescription] = React.useState(''); - // Build filter object const filter: SessionsFilter = React.useMemo( () => ({ @@ -90,39 +82,16 @@ export function SessionsPage() { } = useSessions({ filter }); // Mutations - const { createSession, isCreating } = useCreateSession(); const { archiveSession, isArchiving } = useArchiveSession(); const { deleteSession, isDeleting } = useDeleteSession(); - const isMutating = isCreating || isArchiving || isDeleting; + const isMutating = isArchiving || isDeleting; // Handlers const handleSessionClick = (sessionId: string) => { navigate(`/sessions/${sessionId}`); }; - const handleCreateSession = async () => { - if (!newSessionId.trim()) return; - - try { - await createSession({ - session_id: newSessionId.trim(), - title: newSessionTitle.trim() || undefined, - description: newSessionDescription.trim() || undefined, - }); - setCreateDialogOpen(false); - resetCreateForm(); - } catch (err) { - console.error('Failed to create session:', err); - } - }; - - const resetCreateForm = () => { - setNewSessionId(''); - setNewSessionTitle(''); - setNewSessionDescription(''); - }; - const handleArchive = async (sessionId: string) => { try { await archiveSession(sessionId); @@ -185,10 +154,6 @@ export function SessionsPage() { {formatMessage({ id: 'common.actions.refresh' })} -
@@ -325,15 +290,10 @@ export function SessionsPage() { ? formatMessage({ id: 'sessions.emptyState.message' }) : formatMessage({ id: 'sessions.emptyState.createFirst' })}

- {hasActiveFilters ? ( + {hasActiveFilters && ( - ) : ( - )}
) : ( @@ -352,70 +312,6 @@ export function SessionsPage() {
)} - {/* Create Session Dialog */} - - - - {formatMessage({ id: 'common.dialog.createSession' })} - - {formatMessage({ id: 'common.dialog.createSessionDesc' })} - - -
-
- - setNewSessionId(e.target.value)} - /> -
-
- - setNewSessionTitle(e.target.value)} - /> -
-
- - setNewSessionDescription(e.target.value)} - /> -
-
- - - - -
-
- {/* Delete Confirmation Dialog */} diff --git a/ccw/frontend/src/pages/SkillsManagerPage.tsx b/ccw/frontend/src/pages/SkillsManagerPage.tsx index 694cc5ec..d7ac43fd 100644 --- a/ccw/frontend/src/pages/SkillsManagerPage.tsx +++ b/ccw/frontend/src/pages/SkillsManagerPage.tsx @@ -13,12 +13,28 @@ import { Power, PowerOff, Tag, + ChevronDown, + ChevronRight, + EyeOff, + List, + Grid3x3, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui'; import { SkillCard } from '@/components/shared/SkillCard'; +import { LocationSwitcher } from '@/components/commands/LocationSwitcher'; import { useSkills, useSkillMutations } from '@/hooks'; import type { Skill } from '@/lib/api'; import { cn } from '@/lib/utils'; @@ -90,12 +106,17 @@ export function SkillsManagerPage() { const [sourceFilter, setSourceFilter] = useState('all'); const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all'); const [viewMode, setViewMode] = useState<'grid' | 'compact'>('grid'); + const [showDisabledSection, setShowDisabledSection] = useState(false); + const [confirmDisable, setConfirmDisable] = useState<{ skill: Skill; enable: boolean } | null>(null); + const [locationFilter, setLocationFilter] = useState<'project' | 'user'>('project'); const { skills, categories, totalCount, enabledCount, + projectSkills, + userSkills, isLoading, isFetching, refetch, @@ -105,11 +126,15 @@ export function SkillsManagerPage() { category: categoryFilter !== 'all' ? categoryFilter : undefined, source: sourceFilter !== 'all' ? sourceFilter as Skill['source'] : undefined, enabledOnly: enabledFilter === 'enabled', + location: locationFilter, }, }); const { toggleSkill, isToggling } = useSkillMutations(); + // Calculate disabled count + const disabledCount = totalCount - enabledCount; + // Filter skills based on enabled filter const filteredSkills = useMemo(() => { if (enabledFilter === 'disabled') { @@ -119,32 +144,63 @@ export function SkillsManagerPage() { }, [skills, enabledFilter]); const handleToggle = async (skill: Skill, enabled: boolean) => { - await toggleSkill(skill.name, enabled); + // Use the skill's location property + const location = skill.location || 'project'; + await toggleSkill(skill.name, enabled, location); + }; + + const handleToggleWithConfirm = (skill: Skill, enabled: boolean) => { + if (!enabled) { + // Show confirmation dialog when disabling + setConfirmDisable({ skill, enable: false }); + } else { + // Enable directly without confirmation + handleToggle(skill, true); + } + }; + + const handleConfirmDisable = async () => { + if (confirmDisable) { + await handleToggle(confirmDisable.skill, false); + setConfirmDisable(null); + } }; return (
{/* Page Header */} -
-
-

- - {formatMessage({ id: 'skills.title' })} -

-

- {formatMessage({ id: 'skills.description' })} -

-
-
- - +
+
+
+

+ + {formatMessage({ id: 'skills.title' })} +

+

+ {formatMessage({ id: 'skills.description' })} +

+
+
+ + +
+ + {/* Location Switcher */} +
{/* Stats Cards */} @@ -229,34 +285,39 @@ export function SkillsManagerPage() { {/* Quick Filters */}
@@ -265,11 +326,58 @@ export function SkillsManagerPage() { {}} isToggling={isToggling} compact={viewMode === 'compact'} /> + + {/* Disabled Skills Section */} + {enabledFilter === 'all' && disabledCount > 0 && ( +
+ + + {showDisabledSection && ( + !s.enabled)} + isLoading={false} + onToggle={handleToggleWithConfirm} + onClick={() => {}} + isToggling={isToggling} + compact={true} + /> + )} +
+ )} + + {/* Disable Confirmation Dialog */} + !open && setConfirmDisable(null)}> + + + {formatMessage({ id: 'skills.disableConfirm.title' })} + + {formatMessage( + { id: 'skills.disableConfirm.message' }, + { name: confirmDisable?.skill.name || '' } + )} + + + + {formatMessage({ id: 'skills.actions.cancel' })} + + {formatMessage({ id: 'skills.actions.confirmDisable' })} + + + +
); } diff --git a/ccw/frontend/src/pages/session-detail/SummaryTab.tsx b/ccw/frontend/src/pages/session-detail/SummaryTab.tsx index e5e18118..9c084659 100644 --- a/ccw/frontend/src/pages/session-detail/SummaryTab.tsx +++ b/ccw/frontend/src/pages/session-detail/SummaryTab.tsx @@ -71,29 +71,14 @@ export function SummaryTab({ summary, summaries }: SummaryTabProps) { return ( <>
- {summaryList.length === 1 ? ( - // Single summary - inline display - - -

- - {summaryList[0].name} -

-
-

{summaryList[0].content}

-
-
-
- ) : ( - // Multiple summaries - card list with modal viewer - summaryList.map((item, index) => ( - setSelectedSummary(item)} - /> - )) - )} + {/* Always use truncated card display with modal viewer */} + {summaryList.map((item, index) => ( + setSelectedSummary(item)} + /> + ))}
{/* Modal Viewer */} @@ -119,24 +104,21 @@ interface SummaryCardProps { function SummaryCard({ summary, onClick }: SummaryCardProps) { const { formatMessage } = useIntl(); - - // Get preview (first 3 lines) + + // Get preview (first 5 lines, matching ImplPlanTab style) const lines = summary.content.split('\n'); - const preview = lines.slice(0, 3).join('\n'); - const hasMore = lines.length > 3; + const preview = lines.slice(0, 5).join('\n'); + const hasMore = lines.length > 5; return ( - +
{summary.name} - @@ -147,10 +129,15 @@ function SummaryCard({ summary, onClick }: SummaryCardProps) { {preview}{hasMore && '\n...'} {hasMore && ( -
- - {lines.length} {formatMessage({ id: 'sessionDetail.summary.lines' })} - +
+
)} diff --git a/ccw/frontend/src/pages/session-detail/TaskListTab.tsx b/ccw/frontend/src/pages/session-detail/TaskListTab.tsx index dba56f9f..2c01728a 100644 --- a/ccw/frontend/src/pages/session-detail/TaskListTab.tsx +++ b/ccw/frontend/src/pages/session-detail/TaskListTab.tsx @@ -8,16 +8,31 @@ import { useIntl } from 'react-intl'; import { ListChecks, Code, + GitBranch, + Zap, + Calendar, + FileCode, + Layers, } from 'lucide-react'; +import { Badge } from '@/components/ui/Badge'; import { Card, CardContent } from '@/components/ui/Card'; import { TaskStatsBar, TaskStatusDropdown } from '@/components/session-detail/tasks'; import type { SessionMetadata, TaskData } from '@/types/store'; -import type { TaskStatus } from '@/lib/api'; +import type { TaskStatus, FlowControl } from '@/lib/api'; import { bulkUpdateTaskStatus, updateTaskStatus } from '@/lib/api'; -export interface TaskListTabProps { - session: SessionMetadata; - onTaskClick?: (task: TaskData) => void; +// Extended task type with all possible fields from JSON +interface ExtendedTask extends TaskData { + meta?: { + type?: string; + scope?: string; + }; + context?: { + focus_paths?: string[]; + acceptance?: string[]; + depends_on?: string[]; + }; + flow_control?: FlowControl; } export interface TaskListTabProps { @@ -52,62 +67,72 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) { // Get session path for API calls const sessionPath = (session as any).path || session.session_id; - // Bulk action handlers + // Bulk action handlers - mark ALL tasks (not just filtered ones) to the target status const handleMarkAllPending = async () => { - const targetTasks = localTasks.filter((t) => t.status === 'pending'); + // Mark all non-pending tasks as pending + const targetTasks = localTasks.filter((t) => t.status !== 'pending'); if (targetTasks.length === 0) return; setIsLoadingPending(true); + // Optimistic update + setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'pending' as const }))); try { const taskIds = targetTasks.map((t) => t.task_id); const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'pending'); - if (result.success) { - // Optimistic update - will be refreshed when parent re-renders - } else { + if (!result.success) { console.error('[TaskListTab] Failed to mark all as pending:', result.error); + // Rollback on error + setLocalTasks(tasks); } } catch (error) { console.error('[TaskListTab] Failed to mark all as pending:', error); + setLocalTasks(tasks); } finally { setIsLoadingPending(false); } }; const handleMarkAllInProgress = async () => { - const targetTasks = localTasks.filter((t) => t.status === 'in_progress'); + // Mark all non-in_progress tasks as in_progress + const targetTasks = localTasks.filter((t) => t.status !== 'in_progress'); if (targetTasks.length === 0) return; setIsLoadingInProgress(true); + // Optimistic update + setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'in_progress' as const }))); try { const taskIds = targetTasks.map((t) => t.task_id); const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'in_progress'); - if (result.success) { - // Optimistic update - will be refreshed when parent re-renders - } else { + if (!result.success) { console.error('[TaskListTab] Failed to mark all as in_progress:', result.error); + setLocalTasks(tasks); } } catch (error) { console.error('[TaskListTab] Failed to mark all as in_progress:', error); + setLocalTasks(tasks); } finally { setIsLoadingInProgress(false); } }; const handleMarkAllCompleted = async () => { - const targetTasks = localTasks.filter((t) => t.status === 'completed'); + // Mark all non-completed tasks as completed + const targetTasks = localTasks.filter((t) => t.status !== 'completed'); if (targetTasks.length === 0) return; setIsLoadingCompleted(true); + // Optimistic update + setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'completed' as const }))); try { const taskIds = targetTasks.map((t) => t.task_id); const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'completed'); - if (result.success) { - // Optimistic update - will be refreshed when parent re-renders - } else { + if (!result.success) { console.error('[TaskListTab] Failed to mark all as completed:', result.error); + setLocalTasks(tasks); } } catch (error) { console.error('[TaskListTab] Failed to mark all as completed:', error); + setLocalTasks(tasks); } finally { setIsLoadingCompleted(false); } @@ -170,6 +195,32 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) { ) : (
{localTasks.map((task, index) => { + // Cast to extended type to access all possible fields + const extTask = task as unknown as ExtendedTask; + + // Priority config + const priorityConfig: Record = { + critical: { label: formatMessage({ id: 'sessionDetail.tasks.priority.critical' }), variant: 'destructive' }, + high: { label: formatMessage({ id: 'sessionDetail.tasks.priority.high' }), variant: 'warning' }, + medium: { label: formatMessage({ id: 'sessionDetail.tasks.priority.medium' }), variant: 'info' }, + low: { label: formatMessage({ id: 'sessionDetail.tasks.priority.low' }), variant: 'secondary' }, + }; + const priority = extTask.priority ? priorityConfig[extTask.priority] : null; + + // Get depends_on from either root level or context + const dependsOn = extTask.depends_on || extTask.context?.depends_on || []; + const dependsCount = dependsOn.length; + + // Get meta info + const taskType = extTask.meta?.type; + const taskScope = extTask.meta?.scope; + + // Get implementation steps count from flow_control + const stepsCount = extTask.flow_control?.implementation_approach?.length || 0; + + // Get target files count + const filesCount = extTask.flow_control?.target_files?.length || 0; + return ( onTaskClick?.(task as TaskData)} > -
+
+ {/* Left: Task ID, Title, Description */}
-
- +
+ {task.task_id} - handleTaskStatusChange(task.task_id, newStatus)} - size="sm" - /> - {task.priority && ( - - {task.priority} - - )}

{task.title || formatMessage({ id: 'sessionDetail.tasks.untitled' })} @@ -202,18 +244,63 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) { {task.description}

)} - {task.depends_on && task.depends_on.length > 0 && ( -
- - Depends on: {task.depends_on.join(', ')} +
+ + {/* Right: Status and Meta info */} +
+ {/* Row 1: Status dropdown */} + handleTaskStatusChange(task.task_id, newStatus)} + size="sm" + /> + + {/* Row 2: Meta info */} +
+ {priority && ( + + + {priority.label} + + )} + {taskType && ( + {taskType} + )} + {stepsCount > 0 && ( + + + {stepsCount} {formatMessage({ id: 'sessionDetail.tasks.steps' })} + + )} + {filesCount > 0 && ( + + + {filesCount} {formatMessage({ id: 'sessionDetail.tasks.files' })} + + )} + {dependsCount > 0 && ( + + + {dependsCount} {formatMessage({ id: 'sessionDetail.tasks.deps' })} + + )} +
+ + {/* Row 3: Scope or Date */} + {(taskScope || task.created_at) && ( +
+ {taskScope && ( + {taskScope} + )} + {task.created_at && ( + + + {new Date(task.created_at).toLocaleDateString()} + + )}
)}
- {task.created_at && ( -
- {new Date(task.created_at).toLocaleDateString()} -
- )}

diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index ebcdf2b4..f4ad91b2 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -88,6 +88,7 @@ export function run(argv: string[]): void { .option('--host ', 'Server host to bind', '127.0.0.1') .option('--no-browser', 'Start server without opening browser') .option('--frontend ', 'Frontend type: js, react, both', 'both') + .option('--new', 'Launch React frontend (shorthand for --frontend react)') .action(viewCommand); // Serve command (alias for view) @@ -99,6 +100,7 @@ export function run(argv: string[]): void { .option('--host ', 'Server host to bind', '127.0.0.1') .option('--no-browser', 'Start server without opening browser') .option('--frontend ', 'Frontend type: js, react, both', 'both') + .option('--new', 'Launch React frontend (shorthand for --frontend react)') .action(serveCommand); // Stop command diff --git a/ccw/src/commands/serve.ts b/ccw/src/commands/serve.ts index d2d3a9f8..ff344850 100644 --- a/ccw/src/commands/serve.ts +++ b/ccw/src/commands/serve.ts @@ -11,6 +11,7 @@ interface ServeOptions { host?: string; browser?: boolean; frontend?: 'js' | 'react' | 'both'; + new?: boolean; } /** @@ -20,7 +21,8 @@ interface ServeOptions { export async function serveCommand(options: ServeOptions): Promise { const port = Number(options.port) || 3456; const host = options.host || '127.0.0.1'; - const frontend = options.frontend || 'js'; + // --new flag is shorthand for --frontend react + const frontend = options.new ? 'react' : (options.frontend || 'js'); // Validate project path let initialPath = process.cwd(); diff --git a/ccw/src/commands/view.ts b/ccw/src/commands/view.ts index 68bf5e12..577101b2 100644 --- a/ccw/src/commands/view.ts +++ b/ccw/src/commands/view.ts @@ -10,6 +10,7 @@ interface ViewOptions { host?: string; browser?: boolean; frontend?: 'js' | 'react' | 'both'; + new?: boolean; } interface SwitchWorkspaceResult { @@ -76,7 +77,8 @@ export async function viewCommand(options: ViewOptions): Promise { const port = Number(options.port) || 3456; const host = options.host || '127.0.0.1'; const browserHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host; - const frontend = options.frontend || 'both'; + // --new flag is shorthand for --frontend react + const frontend = options.new ? 'react' : (options.frontend || 'both'); // Resolve workspace path let workspacePath = process.cwd(); diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index 2f8bbea4..e1feae6d 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; +import { spawn } from 'child_process'; import type { RouteContext } from './types.js'; @@ -606,18 +607,18 @@ async function executeCliCommand( }); if (child.stdout) { - child.stdout.on('data', (data) => { + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); } if (child.stderr) { - child.stderr.on('data', (data) => { + child.stderr.on('data', (data: Buffer) => { errorOutput += data.toString(); }); } - child.on('close', (code) => { + child.on('close', (code: number | null) => { if (code === 0) { resolve({ success: true, @@ -632,11 +633,11 @@ async function executeCliCommand( } }); - child.on('error', (err) => { + child.on('error', (err: Error) => { resolve({ success: false, output: '', - error: (err as Error).message + error: err.message }); }); }); diff --git a/ccw/src/templates/dashboard-js/components/hook-manager.js b/ccw/src/templates/dashboard-js/components/hook-manager.js index 87d987ac..abca140a 100644 --- a/ccw/src/templates/dashboard-js/components/hook-manager.js +++ b/ccw/src/templates/dashboard-js/components/hook-manager.js @@ -275,6 +275,75 @@ const HOOK_TEMPLATES = { description: 'Confirm before changing file permissions (chmod, chown, icacls)', category: 'danger', timeout: 5000 + }, + + // ========== Session Start Hooks ========== + 'session-start-notify': { + event: 'SessionStart', + matcher: '', + command: 'node', + args: ['-e', 'const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_CREATED",timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})'], + description: 'Notify dashboard when session starts or resumes', + category: 'session', + timeout: 5000 + }, + 'session-list-sync': { + event: 'UserPromptSubmit', + matcher: '', + command: 'node', + args: ['-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const prompt=(p.user_prompt||"").toLowerCase();if(prompt.includes("session")&&(prompt.includes("list")||prompt==="sessions")){const cp=require("child_process");cp.spawnSync("ccw",["session","list","--metadata"],{stdio:"inherit"})}'], + description: 'Auto-sync session list when user views sessions', + category: 'session', + timeout: 10000 + }, + 'session-state-watch': { + event: 'PostToolUse', + matcher: 'Write|Edit', + command: 'node', + args: ['-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/workflow-session\\.json$|session-metadata\\.json$/.test(file)){const fs=require("fs");try{const content=fs.readFileSync(file,"utf8");const data=JSON.parse(content);const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_STATE_CHANGED",file:file,sessionId:data.session_id||"",status:data.status||"unknown",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}catch(e){}}'], + description: 'Watch for session metadata file changes (workflow-session.json, session-metadata.json)', + category: 'session', + timeout: 5000 + }, + + // ========== CCW Status Hooks ========== + 'ccw-status-monitor': { + event: 'UserPromptSubmit', + matcher: '', + command: 'node', + args: ['-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const prompt=(p.user_prompt||"").toLowerCase();if(prompt==="status"||prompt==="ccw status"||prompt.startsWith("/status")){const cp=require("child_process");cp.spawnSync("curl",["-s","http://localhost:3456/api/status/all"],{stdio:"inherit"})}'], + description: 'Monitor CCW service status on status-related commands', + category: 'monitoring', + timeout: 10000 + }, + 'ccw-health-check': { + event: 'UserPromptSubmit', + matcher: '', + command: 'node', + args: ['-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const prompt=(p.user_prompt||"").toLowerCase();if(prompt.includes("health")||prompt.includes("check")){const cp=require("child_process");const urls=["http://localhost:3456/api/status/all","http://localhost:3456/api/cli/active"];urls.forEach(url=>{cp.spawnSync("curl",["-s",url],{stdio:"inherit"})})}'], + description: 'Health check for CCW services (status API, active CLI executions)', + category: 'monitoring', + timeout: 15000 + }, + 'ccw-cli-active-sync': { + event: 'UserPromptSubmit', + matcher: '', + command: 'node', + args: ['-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const prompt=(p.user_prompt||"").toLowerCase();if(prompt.includes("cli")&&(prompt.includes("active")||prompt.includes("running")||prompt.includes("status"))){const cp=require("child_process");cp.spawnSync("curl",["-s","http://localhost:3456/api/cli/active"],{stdio:"inherit"})}'], + description: 'Sync active CLI executions when requested', + category: 'monitoring', + timeout: 10000 + }, + + // ========== WebSocket Connection Hook ========== + 'ccw-websocket-notify': { + event: 'UserPromptSubmit', + matcher: '', + command: 'node', + args: ['-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const WebSocket=require("ws");if(process.platform==="win32"){console.log("WebSocket notification: Skip on Windows");return}const ws=new WebSocket("ws://localhost:3456/ws");ws.on("open",()=>{ws.send(JSON.stringify({type:"CLIENT_HELLO",timestamp:Date.now(),client:"hook-manager"}));ws.close()});ws.on("error",(e)=>{console.log("WebSocket connection failed (dashboard may not be running)")})'], + description: 'Test WebSocket connection to CCW dashboard (Unix only)', + category: 'monitoring', + timeout: 5000 } }; diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index b1768878..7dede8e8 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -1126,6 +1126,7 @@ const i18n = { 'hook.createTitle': 'Create Hook', 'hook.event': 'Hook Event', 'hook.selectEvent': 'Select an event...', + 'hook.sessionStart': 'SessionStart - When session starts or resumes', 'hook.preToolUse': 'PreToolUse - Before a tool is executed', 'hook.postToolUse': 'PostToolUse - After a tool completes', 'hook.notification': 'Notification - On notifications', @@ -1154,6 +1155,16 @@ const i18n = { // Hook Quick Install Templates 'hook.tpl.sessionContext': 'Session Context', 'hook.tpl.sessionContextDesc': 'Load cluster overview once at session start', + 'hook.tpl.sessionStart': 'Session Start Notification', + 'hook.tpl.sessionStartDesc': 'Notify dashboard when a new workflow session is created', + 'hook.tpl.sessionState': 'Session State Watcher', + 'hook.tpl.sessionStateDesc': 'Watch for session metadata file changes', + 'hook.tpl.ccwStatus': 'CCW Status Monitor', + 'hook.tpl.ccwStatusDesc': 'Monitor CCW service status on status commands', + 'hook.tpl.cliActive': 'CLI Active Sync', + 'hook.tpl.cliActiveDesc': 'Sync active CLI executions when requested', + 'hook.tpl.healthCheck': 'CCW Health Check', + 'hook.tpl.healthCheckDesc': 'Health check for CCW services (status, CLI)', 'hook.tpl.codexlensSync': 'CodexLens Auto-Sync', 'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited', 'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify', @@ -1174,6 +1185,9 @@ const i18n = { 'hook.category.memory': 'memory', 'hook.category.skill': 'skill', 'hook.category.context': 'context', + 'hook.category.session': 'session', + 'hook.category.monitoring': 'monitoring', + 'hook.category.danger': 'danger', // Hook Wizard Templates 'hook.wizard.memoryUpdate': 'Memory Update Hook', @@ -3808,6 +3822,7 @@ const i18n = { 'hook.createTitle': '创建钩子', 'hook.event': '钩子事件', 'hook.selectEvent': '选择事件...', + 'hook.sessionStart': 'SessionStart - 会话启动或恢复时', 'hook.preToolUse': 'PreToolUse - 工具执行前', 'hook.postToolUse': 'PostToolUse - 工具完成后', 'hook.notification': 'Notification - 通知时', @@ -3836,6 +3851,16 @@ const i18n = { // Hook Quick Install Templates 'hook.tpl.sessionContext': 'Session 上下文', 'hook.tpl.sessionContextDesc': '会话启动时加载集群概览(仅触发一次)', + 'hook.tpl.sessionStart': 'Session 启动通知', + 'hook.tpl.sessionStartDesc': '创建新的工作流会话时通知控制面板', + 'hook.tpl.sessionState': 'Session 状态监听', + 'hook.tpl.sessionStateDesc': '监听 session 元数据文件变更', + 'hook.tpl.ccwStatus': 'CCW 状态监控', + 'hook.tpl.ccwStatusDesc': '在状态相关命令下监控 CCW 服务状态', + 'hook.tpl.cliActive': 'CLI 活跃同步', + 'hook.tpl.cliActiveDesc': '请求时同步活跃的 CLI 执行', + 'hook.tpl.healthCheck': 'CCW 健康检查', + 'hook.tpl.healthCheckDesc': 'CCW 服务健康检查(状态、CLI)', 'hook.tpl.codexlensSync': 'CodexLens 自动同步', 'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引', 'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知', @@ -3856,6 +3881,9 @@ const i18n = { 'hook.category.memory': '记忆', 'hook.category.skill': '技能', 'hook.category.context': '上下文', + 'hook.category.session': '会话', + 'hook.category.monitoring': '监控', + 'hook.category.danger': '危险防护', // Hook Wizard Templates 'hook.wizard.memoryUpdate': '记忆更新钩子', diff --git a/ccw/src/templates/dashboard-js/views/hook-manager.js b/ccw/src/templates/dashboard-js/views/hook-manager.js index 1fc82211..17d9f45e 100644 --- a/ccw/src/templates/dashboard-js/views/hook-manager.js +++ b/ccw/src/templates/dashboard-js/views/hook-manager.js @@ -100,12 +100,9 @@ async function renderHookManager() {
- ${renderQuickInstallCard('session-context', t('hook.tpl.sessionContext'), t('hook.tpl.sessionContextDesc'), 'UserPromptSubmit', '')} - ${renderQuickInstallCard('codexlens-update', t('hook.tpl.codexlensSync'), t('hook.tpl.codexlensSyncDesc'), 'PostToolUse', 'Write|Edit')} - ${renderQuickInstallCard('ccw-notify', t('hook.tpl.ccwDashboardNotify'), t('hook.tpl.ccwDashboardNotifyDesc'), 'PostToolUse', 'Write')} - ${renderQuickInstallCard('log-tool', t('hook.tpl.toolLogger'), t('hook.tpl.toolLoggerDesc'), 'PostToolUse', 'All')} - ${renderQuickInstallCard('lint-check', t('hook.tpl.autoLint'), t('hook.tpl.autoLintDesc'), 'PostToolUse', 'Write')} - ${renderQuickInstallCard('git-add', t('hook.tpl.autoGitStage'), t('hook.tpl.autoGitStageDesc'), 'PostToolUse', 'Write')} + + ${renderQuickInstallCard('session-start-notify', t('hook.tpl.sessionStart'), t('hook.tpl.sessionStartDesc'), 'SessionStart', '')} + ${renderQuickInstallCard('session-state-watch', t('hook.tpl.sessionState'), t('hook.tpl.sessionStateDesc'), 'PostToolUse', 'Write|Edit')}