mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-30 20:21:09 +08:00
feat: update usage recommendations across multiple workflow commands to require user confirmation and improve clarity
This commit is contained in:
@@ -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
|
**Use `Skill(skill="ccw-debug", args="\"bug description\"")` when:**
|
||||||
2. **Quick Decision**: Use CLI Quick (--mode cli) for immediate recommendations
|
- First time: Use default mode (debug-first), observe workflow
|
||||||
3. **Quick Fix**: Use `--hotfix --yes` for minimal diagnostics (debug mode)
|
|
||||||
4. **Learning**: Use debug-first, read `understanding.md`
|
**Use `Skill(skill="ccw-debug", args="--mode cli \"issue\"")` when:**
|
||||||
5. **Complete Validation**: Use bidirectional for multi-dimensional insights
|
- Quick Decision: Immediate recommendations without full workflow
|
||||||
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="--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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -515,29 +515,35 @@ User agrees with current direction, wants deeper code analysis
|
|||||||
- Current design allows horizontal scaling without session affinity
|
- 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
|
- Exploring a complex topic collaboratively
|
||||||
- Need documented discussion trail
|
- Need documented discussion trail
|
||||||
- Decision-making requires multiple perspectives
|
- Decision-making requires multiple perspectives
|
||||||
- Want to iterate on understanding with user input
|
- Want to iterate on understanding with user input
|
||||||
- Building shared understanding before implementation
|
- 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
|
- Diagnosing specific bugs
|
||||||
- Need hypothesis-driven investigation
|
- Need hypothesis-driven investigation
|
||||||
- Focus on evidence and verification
|
- 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
|
- Generating new ideas or solutions
|
||||||
- Need creative exploration
|
- Need creative exploration
|
||||||
- Want divergent thinking before convergence
|
- 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)
|
- Ready to implement (past analysis phase)
|
||||||
- Need structured task breakdown
|
- Need simple task breakdown
|
||||||
- Focus on execution planning
|
- Focus on quick execution planning
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -745,25 +745,31 @@ Dimensions matched against topic keywords to identify focus areas:
|
|||||||
|
|
||||||
See full markdown template in original file (lines 955-1161).
|
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
|
- Starting a new feature/product without clear direction
|
||||||
- Facing a complex problem with multiple possible solutions
|
- Facing a complex problem with multiple possible solutions
|
||||||
- Need to explore alternatives before committing
|
- Need to explore alternatives before committing
|
||||||
- Want documented thinking process for team review
|
- Want documented thinking process for team review
|
||||||
- Combining multiple stakeholder perspectives
|
- Combining multiple stakeholder perspectives
|
||||||
|
|
||||||
**Use `/workflow:analyze-with-file` when:**
|
**Use `Skill(skill="workflow:analyze-with-file", args="\"topic\"")` when:**
|
||||||
- Investigating existing code/system
|
- Investigating existing code/system
|
||||||
- Need factual analysis over ideation
|
- Need factual analysis over ideation
|
||||||
- Debugging or troubleshooting
|
- Debugging or troubleshooting
|
||||||
- Understanding current state
|
- 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
|
- Direction is already clear
|
||||||
- Ready to move from ideas to execution
|
- Ready to move from ideas to execution
|
||||||
- Need implementation breakdown
|
- Need simple implementation breakdown
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -658,15 +658,15 @@ Why is config value None during update?
|
|||||||
| Hypothesis history | ❌ | ✅ hypotheses.json |
|
| Hypothesis history | ❌ | ✅ hypotheses.json |
|
||||||
| Gemini validation | ❌ | ✅ At key decision points |
|
| 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
|
- Complex bugs requiring multiple investigation rounds
|
||||||
- Learning from debugging process is valuable
|
- Learning from debugging process is valuable
|
||||||
- Team needs to understand debugging rationale
|
- Team needs to understand debugging rationale
|
||||||
- Bug might recur, documentation helps prevention
|
- Bug might recur, documentation helps prevention
|
||||||
|
|
||||||
Use `/workflow:debug` when:
|
**Use `Skill(skill="ccw-debug", args="--mode cli \"issue\"")` when:**
|
||||||
- Simple, quick bugs
|
- Simple, quick bugs
|
||||||
- One-off issues
|
- One-off issues
|
||||||
- Documentation overhead not needed
|
- Documentation overhead not needed
|
||||||
|
|||||||
453
.claude/docs/HOOKS_ANALYSIS_REPORT.md
Normal file
453
.claude/docs/HOOKS_ANALYSIS_REPORT.md
Normal file
@@ -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 时,能够正常工作。
|
||||||
224
.claude/docs/HOOKS_DOCUMENTATION_INDEX.md
Normal file
224
.claude/docs/HOOKS_DOCUMENTATION_INDEX.md
Normal file
@@ -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` 获取完整技术细节。
|
||||||
124
.claude/docs/HOOKS_OFFICIAL_GUIDE.md
Normal file
124
.claude/docs/HOOKS_OFFICIAL_GUIDE.md
Normal file
@@ -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__<server>__<tool>` (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
|
||||||
268
.claude/docs/HOOKS_OFFICIAL_REFERENCE.md
Normal file
268
.claude/docs/HOOKS_OFFICIAL_REFERENCE.md
Normal file
@@ -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
|
||||||
390
.claude/docs/HOOKS_QUICK_REFERENCE.md
Normal file
390
.claude/docs/HOOKS_QUICK_REFERENCE.md
Normal file
@@ -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__<server>__<tool>
|
||||||
|
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` - 官方示例脚本
|
||||||
85
.claude/examples/hooks_bash_command_validator.py
Normal file
85
.claude/examples/hooks_bash_command_validator.py
Normal file
@@ -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()
|
||||||
401
ccw/frontend/src/components/commands/CommandGroupAccordion.tsx
Normal file
401
ccw/frontend/src/components/commands/CommandGroupAccordion.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
terminal: ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<polyline points="4 17 10 11 4 5" />
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
'git-branch': ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<line x1="6" y1="3" x2="6" y2="15" />
|
||||||
|
<circle cx="18" cy="6" r="3" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
brain: ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
|
||||||
|
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
'clipboard-list': ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||||
|
<path d="M12 11h4" />
|
||||||
|
<path d="M12 16h4" />
|
||||||
|
<path d="M8 11h.01" />
|
||||||
|
<path d="M8 16h.01" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
'alert-circle': ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
repeat: ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="m17 2 4 4-4 4" />
|
||||||
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||||
|
<path d="m7 22-4-4 4-4" />
|
||||||
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
sparkles: ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||||
|
<path d="M5 3v4" />
|
||||||
|
<path d="M19 17v4" />
|
||||||
|
<path d="M3 5h4" />
|
||||||
|
<path d="M17 19h4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
folder: ({ className }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={cn('mb-4', indentLevel > 0 && 'ml-5')} style={indentLevel > 0 ? { marginLeft: `${indentLevel * 20}px` } : undefined}>
|
||||||
|
<Collapsible open={isExpanded} onOpenChange={(open) => onToggleExpand(groupName)}>
|
||||||
|
{/* Group Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-card border border-border rounded-lg hover:bg-muted/50 transition-colors">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<div className="flex items-center gap-3 flex-1 cursor-pointer">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-5 h-5 text-muted-foreground transition-transform" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5 text-muted-foreground transition-transform" />
|
||||||
|
)}
|
||||||
|
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', colorClass)}>
|
||||||
|
<IconComponent className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{displayName}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{enabledCommands.length}/{commands.length} {formatMessage({ id: 'commands.group.enabled' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Group Toggle Switch */}
|
||||||
|
<Switch
|
||||||
|
checked={allEnabled}
|
||||||
|
onCheckedChange={handleToggleGroup}
|
||||||
|
disabled={isToggling || commands.length === 0}
|
||||||
|
className={cn('data-[state=checked]:bg-success')}
|
||||||
|
title={
|
||||||
|
allEnabled
|
||||||
|
? formatMessage({ id: 'commands.group.clickToDisableAll' })
|
||||||
|
: formatMessage({ id: 'commands.group.clickToEnableAll' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{commands.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group Content - Commands Table */}
|
||||||
|
<CollapsibleContent className="mt-3">
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full" style={{ tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '200px' }} />
|
||||||
|
<col style={{ width: 'auto' }} />
|
||||||
|
<col style={{ width: '100px' }} />
|
||||||
|
<col style={{ width: '80px' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead className="bg-muted/30 border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
|
{formatMessage({ id: 'commands.table.name' })}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
|
{formatMessage({ id: 'commands.table.description' })}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||||
|
{formatMessage({ id: 'commands.table.scope' })}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||||
|
{formatMessage({ id: 'commands.table.status' })}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{visibleCommands.map((command) => (
|
||||||
|
<CommandRow
|
||||||
|
key={command.name}
|
||||||
|
command={command}
|
||||||
|
onToggle={onToggleCommand}
|
||||||
|
disabled={isToggling}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{visibleCommands.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{showDisabled
|
||||||
|
? formatMessage({ id: 'commands.group.noCommands' })
|
||||||
|
: formatMessage({ id: 'commands.group.noEnabledCommands' })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<tr className={cn('hover:bg-muted/20 transition-colors', isDisabled && 'opacity-60')}>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-foreground">
|
||||||
|
<code className="break-words">/{command.name}</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
<div className="line-clamp-2 break-words">
|
||||||
|
{command.description || formatMessage({ id: 'commands.card.noDescription' })}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center text-xs text-muted-foreground">
|
||||||
|
<span className="whitespace-nowrap">{command.location || 'project'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Switch
|
||||||
|
checked={command.enabled}
|
||||||
|
onCheckedChange={(checked) => onToggle(command.name, checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandGroupAccordion;
|
||||||
78
ccw/frontend/src/components/commands/LocationSwitcher.tsx
Normal file
78
ccw/frontend/src/components/commands/LocationSwitcher.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="inline-flex bg-muted rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-all flex items-center gap-1.5',
|
||||||
|
currentLocation === 'project'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => onLocationChange('project')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Folder className="w-3.5 h-3.5" />
|
||||||
|
<span>{formatMessage({ id: `${translationPrefix}.location.project` })}</span>
|
||||||
|
{projectCount !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground">({projectCount})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-all flex items-center gap-1.5',
|
||||||
|
currentLocation === 'user'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => onLocationChange('user')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>{formatMessage({ id: `${translationPrefix}.location.user` })}</span>
|
||||||
|
{userCount !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground">({userCount})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LocationSwitcher;
|
||||||
9
ccw/frontend/src/components/commands/index.ts
Normal file
9
ccw/frontend/src/components/commands/index.ts
Normal file
@@ -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';
|
||||||
@@ -20,7 +20,7 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export type HookTriggerType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
export type HookTriggerType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||||
|
|
||||||
export interface HookCardData {
|
export interface HookCardData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,6 +45,8 @@ export interface HookCardProps {
|
|||||||
|
|
||||||
function getTriggerIcon(trigger: HookTriggerType) {
|
function getTriggerIcon(trigger: HookTriggerType) {
|
||||||
switch (trigger) {
|
switch (trigger) {
|
||||||
|
case 'SessionStart':
|
||||||
|
return '🎬';
|
||||||
case 'UserPromptSubmit':
|
case 'UserPromptSubmit':
|
||||||
return '⚡';
|
return '⚡';
|
||||||
case 'PreToolUse':
|
case 'PreToolUse':
|
||||||
@@ -60,6 +62,8 @@ function getTriggerIcon(trigger: HookTriggerType) {
|
|||||||
|
|
||||||
function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' {
|
function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' {
|
||||||
switch (trigger) {
|
switch (trigger) {
|
||||||
|
case 'SessionStart':
|
||||||
|
return 'default';
|
||||||
case 'UserPromptSubmit':
|
case 'UserPromptSubmit':
|
||||||
return 'default';
|
return 'default';
|
||||||
case 'PreToolUse':
|
case 'PreToolUse':
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export function HookFormDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TRIGGER_OPTIONS: { value: HookTriggerType; label: string }[] = [
|
const TRIGGER_OPTIONS: { value: HookTriggerType; label: string }[] = [
|
||||||
|
{ value: 'SessionStart', label: 'cliHooks.trigger.SessionStart' },
|
||||||
{ value: 'UserPromptSubmit', label: 'cliHooks.trigger.UserPromptSubmit' },
|
{ value: 'UserPromptSubmit', label: 'cliHooks.trigger.UserPromptSubmit' },
|
||||||
{ value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' },
|
{ value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' },
|
||||||
{ value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' },
|
{ value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' },
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface HookTemplate {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: TemplateCategory;
|
category: TemplateCategory;
|
||||||
trigger: 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
trigger: 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||||
command: string;
|
command: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
matcher?: string;
|
matcher?: string;
|
||||||
@@ -57,79 +57,28 @@ export interface HookQuickTemplatesProps {
|
|||||||
*/
|
*/
|
||||||
export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||||
{
|
{
|
||||||
id: 'ccw-status-tracker',
|
id: 'session-start-notify',
|
||||||
name: 'CCW Status Tracker',
|
name: 'Session Start Notify',
|
||||||
description: 'Parse CCW status.json and display current/next command',
|
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',
|
category: 'notification',
|
||||||
trigger: 'PostToolUse',
|
trigger: 'PostToolUse',
|
||||||
matcher: 'Write',
|
matcher: 'Write|Edit',
|
||||||
command: 'bash',
|
command: 'node',
|
||||||
args: [
|
args: [
|
||||||
'-c',
|
'-e',
|
||||||
'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'
|
'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){}}'
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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"'
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function ExternalDependenciesSection({ dependencies }: ExternalDependenciesSecti
|
|||||||
{dependencies.map((dep, index) => (
|
{dependencies.map((dep, index) => (
|
||||||
<Badge key={index} variant="secondary" className="px-3 py-1.5">
|
<Badge key={index} variant="secondary" className="px-3 py-1.5">
|
||||||
{dep.package}
|
{dep.package}
|
||||||
{dep.version && <span className="ml-1 text-muted-foreground">@{dep.version}</span>}
|
{dep.version && <span className="ml-1 text-foreground">@{dep.version}</span>}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
|||||||
// Simplify tool name (e.g., gemini-2.5-pro -> gemini)
|
// Simplify tool name (e.g., gemini-2.5-pro -> gemini)
|
||||||
const toolNameShort = execution.tool.split('-')[0];
|
const toolNameShort = execution.tool.split('-')[0];
|
||||||
|
|
||||||
// Status color mapping
|
// Status color mapping - using softer, semantic colors
|
||||||
const statusColor = {
|
const statusColor = {
|
||||||
running: 'bg-green-500 animate-pulse',
|
running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse',
|
||||||
completed: 'bg-blue-500',
|
completed: 'bg-slate-400 dark:bg-slate-500',
|
||||||
error: 'bg-red-500',
|
error: 'bg-rose-500',
|
||||||
}[execution.status];
|
}[execution.status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,34 +31,36 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
|||||||
value={execution.id}
|
value={execution.id}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm'
|
||||||
: 'bg-muted/50 hover:bg-muted/70',
|
: 'bg-muted/30 hover:bg-muted/50 border-border/30',
|
||||||
'transition-colors'
|
'transition-all'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Status indicator dot */}
|
{/* Status indicator dot */}
|
||||||
<span className={cn('w-2 h-2 rounded-full shrink-0', statusColor)} />
|
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', statusColor)} />
|
||||||
|
|
||||||
{/* Simplified tool name */}
|
{/* Simplified tool name */}
|
||||||
<span className="font-medium">{toolNameShort}</span>
|
<span className="font-medium text-[11px]">{toolNameShort}</span>
|
||||||
|
|
||||||
{/* Execution mode */}
|
{/* Execution mode - show on hover */}
|
||||||
<span className="opacity-70">{execution.mode}</span>
|
<span className="opacity-0 group-hover:opacity-70 text-[10px] transition-opacity">
|
||||||
|
{execution.mode}
|
||||||
{/* Line count statistics */}
|
|
||||||
<span className="text-[10px] opacity-50 tabular-nums">
|
|
||||||
{execution.output.length} lines
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Close button */}
|
{/* Line count statistics - show on hover */}
|
||||||
|
<span className="opacity-0 group-hover:opacity-50 text-[9px] tabular-nums transition-opacity">
|
||||||
|
{execution.output.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Close button - show on hover */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-1 p-0.5 rounded hover:bg-destructive/20 transition-colors"
|
className="ml-0.5 p-0.5 rounded hover:bg-rose-500/20 transition-opacity opacity-0 group-hover:opacity-100"
|
||||||
aria-label="Close execution tab"
|
aria-label="Close execution tab"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-2.5 w-2.5 text-rose-600 dark:text-rose-400" />
|
||||||
</button>
|
</button>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { JsonField } from './JsonField';
|
import { JsonField } from './JsonField';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export interface JsonCardProps {
|
|||||||
type TypeConfig = {
|
type TypeConfig = {
|
||||||
icon: typeof Wrench;
|
icon: typeof Wrench;
|
||||||
label: string;
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
color: string;
|
color: string;
|
||||||
bg: string;
|
bg: string;
|
||||||
};
|
};
|
||||||
@@ -45,38 +48,44 @@ const TYPE_CONFIGS: Record<string, TypeConfig> = {
|
|||||||
tool_call: {
|
tool_call: {
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
label: 'Tool Call',
|
label: 'Tool Call',
|
||||||
color: 'text-green-400',
|
shortLabel: 'Tool',
|
||||||
bg: 'bg-green-950/30 border-green-900/50',
|
color: 'text-indigo-600 dark:text-indigo-400',
|
||||||
|
bg: 'border-l-indigo-500',
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
icon: Info,
|
icon: Info,
|
||||||
label: 'Metadata',
|
label: 'Metadata',
|
||||||
color: 'text-yellow-400',
|
shortLabel: 'Info',
|
||||||
bg: 'bg-yellow-950/30 border-yellow-900/50',
|
color: 'text-slate-600 dark:text-slate-400',
|
||||||
|
bg: 'border-l-slate-400',
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
label: 'System',
|
label: 'System',
|
||||||
color: 'text-blue-400',
|
shortLabel: 'Sys',
|
||||||
bg: 'bg-blue-950/30 border-blue-900/50',
|
color: 'text-slate-600 dark:text-slate-400',
|
||||||
|
bg: 'border-l-slate-400',
|
||||||
},
|
},
|
||||||
stdout: {
|
stdout: {
|
||||||
icon: Code,
|
icon: Code,
|
||||||
label: 'Data',
|
label: 'Data',
|
||||||
color: 'text-cyan-400',
|
shortLabel: 'Out',
|
||||||
bg: 'bg-cyan-950/30 border-cyan-900/50',
|
color: 'text-teal-600 dark:text-teal-400',
|
||||||
|
bg: 'border-l-teal-500',
|
||||||
},
|
},
|
||||||
stderr: {
|
stderr: {
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
label: 'Error',
|
label: 'Error',
|
||||||
color: 'text-red-400',
|
shortLabel: 'Err',
|
||||||
bg: 'bg-red-950/30 border-red-900/50',
|
color: 'text-rose-600 dark:text-rose-400',
|
||||||
|
bg: 'border-l-rose-500',
|
||||||
},
|
},
|
||||||
thought: {
|
thought: {
|
||||||
icon: Brain,
|
icon: Brain,
|
||||||
label: 'Thought',
|
label: 'Thought',
|
||||||
color: 'text-purple-400',
|
shortLabel: '💭',
|
||||||
bg: 'bg-purple-950/30 border-purple-900/50',
|
color: 'text-violet-600 dark:text-violet-400',
|
||||||
|
bg: 'border-l-violet-500',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,97 +97,78 @@ export function JsonCard({
|
|||||||
timestamp,
|
timestamp,
|
||||||
onCopy,
|
onCopy,
|
||||||
}: JsonCardProps) {
|
}: JsonCardProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
|
||||||
|
|
||||||
const entries = Object.entries(data);
|
|
||||||
const visibleCount = isExpanded ? entries.length : 3;
|
|
||||||
const hasMore = entries.length > 3;
|
|
||||||
|
|
||||||
const config = TYPE_CONFIGS[type];
|
const config = TYPE_CONFIGS[type];
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
// Check if data has a 'content' field
|
||||||
<div className={cn('border rounded-lg overflow-hidden my-2', config.bg)}>
|
const hasContentField = 'content' in data && typeof data.content === 'string';
|
||||||
{/* Header */}
|
const content = hasContentField ? (data.content as string) : '';
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-between px-3 py-2 cursor-pointer',
|
|
||||||
'hover:bg-black/5 dark:hover:bg-white/5 transition-colors'
|
|
||||||
)}
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className={cn('h-4 w-4', config.color)} />
|
|
||||||
<span className="text-sm font-medium">{config.label}</span>
|
|
||||||
<Badge variant="secondary" className="text-xs h-5">
|
|
||||||
{entries.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
// If has content field, render as streaming output
|
||||||
{timestamp && (
|
if (hasContentField) {
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
// Check if content looks like markdown
|
||||||
{new Date(timestamp).toLocaleTimeString()}
|
const isMarkdown = content.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||||
</span>
|
|
||||||
|
return (
|
||||||
|
<div className={cn('border-l-2 rounded-r my-1.5 py-1 px-2 group relative bg-background', config.bg)}>
|
||||||
|
<div className="pr-6">
|
||||||
|
{isMarkdown ? (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCopy?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowRaw(!showRaw);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Code className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 transition-transform',
|
|
||||||
isExpanded && 'rotate-90'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className={cn('border-l-2 rounded-r my-1.5 py-1 px-2 group relative bg-background text-xs', config.bg)}>
|
||||||
|
{/* Copy button - show on hover */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyCard}
|
||||||
|
className="absolute top-1 right-1 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted"
|
||||||
|
title="Copy JSON"
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{showRaw ? (
|
<div className="pr-6">
|
||||||
<pre className="p-3 text-xs bg-black/20 overflow-x-auto max-h-60">
|
{entries.slice(0, visibleCount).map(([key, value]) => (
|
||||||
<code>{JSON.stringify(data, null, 2)}</code>
|
<JsonField key={key} fieldName={key} value={value} />
|
||||||
</pre>
|
))}
|
||||||
) : (
|
{hasMore && (
|
||||||
<div className="divide-y divide-border/30">
|
<button
|
||||||
{entries.slice(0, visibleCount).map(([key, value]) => (
|
type="button"
|
||||||
<JsonField key={key} fieldName={key} value={value} />
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
))}
|
className="w-full px-2 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors text-left rounded"
|
||||||
{hasMore && (
|
>
|
||||||
<button
|
{isExpanded
|
||||||
type="button"
|
? '▲ Show less'
|
||||||
onClick={(e) => {
|
: `▼ Show ${entries.length - 1} more`}
|
||||||
e.stopPropagation();
|
</button>
|
||||||
setIsExpanded(!isExpanded);
|
)}
|
||||||
}}
|
</div>
|
||||||
className="w-full px-3 py-2 text-xs text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-left"
|
|
||||||
>
|
|
||||||
{isExpanded
|
|
||||||
? '▲ Show less'
|
|
||||||
: `▼ Show ${entries.length - 3} more fields`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Copy } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface JsonFieldProps {
|
export interface JsonFieldProps {
|
||||||
@@ -8,32 +9,70 @@ export interface JsonFieldProps {
|
|||||||
|
|
||||||
export function JsonField({ fieldName, value }: JsonFieldProps) {
|
export function JsonField({ fieldName, value }: JsonFieldProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const isObject = value !== null && typeof value === 'object';
|
const isObject = value !== null && typeof value === 'object';
|
||||||
const isNested = isObject && (Array.isArray(value) || Object.keys(value).length > 0);
|
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 => {
|
const renderPrimitiveValue = (val: unknown): React.ReactNode => {
|
||||||
if (val === null) return <span className="text-muted-foreground italic">null</span>;
|
if (val === null) return <span className="text-muted-foreground italic">null</span>;
|
||||||
if (typeof val === 'boolean') return <span className="text-purple-400 font-medium">{String(val)}</span>;
|
if (typeof val === 'boolean') return <span className="text-purple-400">{String(val)}</span>;
|
||||||
if (typeof val === 'number') return <span className="text-orange-400 font-mono">{String(val)}</span>;
|
if (typeof val === 'number') return <span className="text-orange-400 font-mono">{String(val)}</span>;
|
||||||
if (typeof val === 'string') {
|
if (typeof val === 'string') {
|
||||||
// Check if it's a JSON string
|
|
||||||
const trimmed = val.trim();
|
const trimmed = val.trim();
|
||||||
|
const isLong = trimmed.length > 80;
|
||||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
return <span className="text-green-400">"{trimmed.substring(0, 30)}..."</span>;
|
return (
|
||||||
|
<span className="text-green-600 flex items-center gap-1">
|
||||||
|
<span>"{trimmed.substring(0, isLong ? 50 : trimmed.length)}{isLong ? '...' : ''}"</span>
|
||||||
|
{isLong && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleCopy(val as string); }}
|
||||||
|
className="opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
||||||
|
title="Copy full value"
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <span className="text-green-400">"{val}"</span>;
|
return (
|
||||||
|
<span className="text-green-600 flex items-center gap-1 group">
|
||||||
|
<span className={isLong ? 'truncate' : ''}> "{val}"</span>
|
||||||
|
{isLong && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleCopy(val as string); }}
|
||||||
|
className="opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted shrink-0"
|
||||||
|
title="Copy full value"
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return String(val);
|
return String(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex items-start gap-2 px-3 py-2 hover:bg-black/5 dark:hover:bg-white/5 transition-colors',
|
'flex items-start gap-2 px-2 py-1 group',
|
||||||
'text-sm'
|
'text-xs'
|
||||||
)}>
|
)}>
|
||||||
{/* Field name */}
|
{/* Field name */}
|
||||||
<span className="shrink-0 font-mono text-cyan-400 min-w-[100px]">
|
<span className="shrink-0 font-mono text-cyan-600 dark:text-cyan-400 min-w-[70px] text-xs">
|
||||||
{fieldName}
|
{fieldName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -46,27 +85,27 @@ export function JsonField({ fieldName, value }: JsonFieldProps) {
|
|||||||
<details
|
<details
|
||||||
open={isExpanded}
|
open={isExpanded}
|
||||||
onToggle={(e) => setIsExpanded(e.currentTarget.open)}
|
onToggle={(e) => setIsExpanded(e.currentTarget.open)}
|
||||||
className="group"
|
className="group/summary"
|
||||||
>
|
>
|
||||||
<summary className="cursor-pointer list-none flex items-center gap-1 hover:text-foreground">
|
<summary className="cursor-pointer list-none flex items-center gap-1 text-xs">
|
||||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
|
<span className="text-muted-foreground">
|
||||||
{isExpanded ? '▼' : '▶'}
|
{isExpanded ? '▼' : '▶'}
|
||||||
</span>
|
</span>
|
||||||
{Array.isArray(value) ? (
|
{Array.isArray(value) ? (
|
||||||
<span className="text-blue-400">Array[{value.length}]</span>
|
<span className="text-blue-500">[{value.length}]</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-yellow-400">Object{'{'}{Object.keys(value).length}{'}'}</span>
|
<span className="text-yellow-500">{'{'}{Object.keys(value).length}{'}'}</span>
|
||||||
)}
|
)}
|
||||||
</summary>
|
</summary>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="ml-4 mt-2 space-y-1">
|
<div className="ml-3 mt-1 space-y-0.5">
|
||||||
{Array.isArray(value)
|
{Array.isArray(value)
|
||||||
? value.map((item, i) => (
|
? value.map((item, i) => (
|
||||||
<div key={i} className="pl-2 border-l border-border/30">
|
<div key={i} className="pl-1 border-l border-border/20">
|
||||||
{typeof item === 'object' && item !== null ? (
|
{typeof item === 'object' && item !== null ? (
|
||||||
<JsonField fieldName={`[${i}]`} value={item} />
|
<JsonField fieldName={`[${i}]`} value={item} />
|
||||||
) : (
|
) : (
|
||||||
renderPrimitiveValue(item)
|
<span className="text-xs">{renderPrimitiveValue(item)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -78,7 +117,9 @@ export function JsonField({ fieldName, value }: JsonFieldProps) {
|
|||||||
)}
|
)}
|
||||||
</details>
|
</details>
|
||||||
) : (
|
) : (
|
||||||
<div className="break-all">{renderPrimitiveValue(value)}</div>
|
<div className="break-all text-xs">
|
||||||
|
{renderPrimitiveValue(value)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,45 +23,23 @@ export interface OutputLineProps {
|
|||||||
// ========== Helper Functions ==========
|
// ========== 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']) {
|
function getOutputLineIcon(type: OutputLineProps['line']['type']) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'thought':
|
case 'thought':
|
||||||
return <Brain className="h-3 w-3" />;
|
return <Brain className="h-3 w-3 text-violet-500" />;
|
||||||
case 'system':
|
case 'system':
|
||||||
return <Settings className="h-3 w-3" />;
|
return <Settings className="h-3 w-3 text-slate-400" />;
|
||||||
case 'stderr':
|
case 'stderr':
|
||||||
return <AlertCircle className="h-3 w-3" />;
|
return <AlertCircle className="h-3 w-3 text-rose-500" />;
|
||||||
case 'metadata':
|
case 'metadata':
|
||||||
return <Info className="h-3 w-3" />;
|
return <Info className="h-3 w-3 text-slate-400" />;
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
return <Wrench className="h-3 w-3" />;
|
return <Wrench className="h-3 w-3 text-indigo-500" />;
|
||||||
case 'stdout':
|
case 'stdout':
|
||||||
default:
|
default:
|
||||||
return <MessageCircle className="h-3 w-3" />;
|
return <MessageCircle className="h-3 w-3 text-teal-500" />;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +50,8 @@ function getOutputLineClass(type: OutputLineProps['line']['type']): string {
|
|||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Auto-detects JSON content and renders with JsonCard
|
* - Auto-detects JSON content and renders with JsonCard
|
||||||
* - Shows appropriate icon based on line type
|
* - Shows colored icon based on line type
|
||||||
* - Applies color styling based on line type
|
* - Different card styles for different types
|
||||||
* - Supports copy functionality
|
* - Supports copy functionality
|
||||||
*/
|
*/
|
||||||
export function OutputLine({ line, onCopy }: OutputLineProps) {
|
export function OutputLine({ line, onCopy }: OutputLineProps) {
|
||||||
@@ -81,25 +59,22 @@ export function OutputLine({ line, onCopy }: OutputLineProps) {
|
|||||||
const jsonDetection = useMemo(() => detectJsonInLine(line.content), [line.content]);
|
const jsonDetection = useMemo(() => detectJsonInLine(line.content), [line.content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex gap-2 text-xs', getOutputLineClass(line.type))}>
|
<div className="text-xs">
|
||||||
{/* Icon indicator */}
|
{jsonDetection.isJson && jsonDetection.parsed ? (
|
||||||
<span className="text-muted-foreground shrink-0 mt-0.5">
|
<JsonCard
|
||||||
{getOutputLineIcon(line.type)}
|
data={jsonDetection.parsed}
|
||||||
</span>
|
type={line.type as 'tool_call' | 'metadata' | 'system' | 'stdout' | 'stderr' | 'thought'}
|
||||||
|
timestamp={undefined}
|
||||||
{/* Content area */}
|
onCopy={() => onCopy?.(line.content)}
|
||||||
<div className="flex-1 min-w-0">
|
/>
|
||||||
{jsonDetection.isJson && jsonDetection.parsed ? (
|
) : (
|
||||||
<JsonCard
|
<div className="flex gap-1.5 items-start">
|
||||||
data={jsonDetection.parsed}
|
<span className="shrink-0 mt-0.5">
|
||||||
type={line.type as 'tool_call' | 'metadata' | 'system' | 'stdout'}
|
{getOutputLineIcon(line.type)}
|
||||||
timestamp={line.timestamp}
|
</span>
|
||||||
onCopy={() => onCopy?.(line.content)}
|
<span className="break-all whitespace-pre-wrap text-foreground flex-1">{line.content}</span>
|
||||||
/>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<span className="break-all whitespace-pre-wrap">{line.content}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// Main components
|
// Main components
|
||||||
export { CliStreamMonitorNew as CliStreamMonitor } from './CliStreamMonitorNew';
|
export { default as CliStreamMonitor } from '../CliStreamMonitorLegacy';
|
||||||
export type { CliStreamMonitorNewProps as CliStreamMonitorProps } from './CliStreamMonitorNew';
|
export type { CliStreamMonitorProps } from '../CliStreamMonitorLegacy';
|
||||||
|
|
||||||
|
export { CliStreamMonitorNew } from './CliStreamMonitorNew';
|
||||||
|
export type { CliStreamMonitorNewProps } from './CliStreamMonitorNew';
|
||||||
|
|
||||||
export { default as CliStreamMonitorLegacy } from '../CliStreamMonitorLegacy';
|
export { default as CliStreamMonitorLegacy } from '../CliStreamMonitorLegacy';
|
||||||
export type { CliStreamMonitorProps as CliStreamMonitorLegacyProps } from '../CliStreamMonitorLegacy';
|
export type { CliStreamMonitorProps as CliStreamMonitorLegacyProps } from '../CliStreamMonitorLegacy';
|
||||||
|
|||||||
@@ -19,27 +19,27 @@ function StatusIndicator({ status, duration }: StatusIndicatorProps) {
|
|||||||
|
|
||||||
if (status === 'thinking') {
|
if (status === 'thinking') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
<span className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||||
{formatMessage({ id: 'cliMonitor.thinking' })}
|
{formatMessage({ id: 'cliMonitor.thinking' })}
|
||||||
<span className="animate-pulse">🟡</span>
|
<span className="animate-pulse">●</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'streaming') {
|
if (status === 'streaming') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
<span className="flex items-center gap-1 text-[10px] text-blue-600 dark:text-blue-400">
|
||||||
{formatMessage({ id: 'cliMonitor.streaming' })}
|
{formatMessage({ id: 'cliMonitor.streaming' })}
|
||||||
<span className="animate-pulse">🔵</span>
|
<span className="animate-pulse">●</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
|
<span className="flex items-center gap-1 text-[10px] text-rose-600 dark:text-rose-400">
|
||||||
Error
|
Err
|
||||||
<span>❌</span>
|
<span>●</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ function StatusIndicator({ status, duration }: StatusIndicatorProps) {
|
|||||||
if (duration !== undefined) {
|
if (duration !== undefined) {
|
||||||
const seconds = (duration / 1000).toFixed(1);
|
const seconds = (duration / 1000).toFixed(1);
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{seconds}s
|
{seconds}s
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -110,28 +110,28 @@ export function AssistantMessage({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-purple-50/50 dark:bg-purple-950/30 border-l-4 border-purple-500 rounded-r-lg overflow-hidden transition-all',
|
'bg-violet-50/60 dark:bg-violet-950/40 border-l-2 border-violet-400 dark:border-violet-500 rounded-r-lg overflow-hidden transition-all',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors',
|
'flex items-center gap-2 px-2.5 py-1.5 cursor-pointer hover:bg-violet-100/40 dark:hover:bg-violet-900/30 transition-colors',
|
||||||
'group'
|
'group'
|
||||||
)}
|
)}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<Bot className="h-4 w-4 text-purple-600 dark:text-purple-400 shrink-0" />
|
<Bot className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400 shrink-0" />
|
||||||
<span className="text-sm font-semibold text-purple-900 dark:text-purple-100">
|
<span className="text-xs font-medium text-violet-900 dark:text-violet-100">
|
||||||
{modelName}
|
{modelName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto">
|
||||||
<StatusIndicator status={status} duration={duration} />
|
<StatusIndicator status={status} duration={duration} />
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
'h-3 w-3 text-muted-foreground transition-transform',
|
||||||
!isExpanded && '-rotate-90'
|
!isExpanded && '-rotate-90'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -141,49 +141,43 @@ export function AssistantMessage({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-2 bg-purple-50/30 dark:bg-purple-950/20">
|
<div className="px-2.5 py-2 bg-violet-50/40 dark:bg-violet-950/30">
|
||||||
<div className="bg-white/50 dark:bg-black/20 rounded border border-purple-200/50 dark:border-purple-800/50 p-3">
|
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
|
||||||
<div className="text-sm text-foreground whitespace-pre-wrap break-words">
|
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata Footer */}
|
{/* Metadata Footer - simplified */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-3 py-1.5 bg-purple-50/30 dark:bg-purple-950/20',
|
'flex items-center justify-between px-2.5 py-1 bg-violet-50/40 dark:bg-violet-950/30',
|
||||||
'text-xs text-muted-foreground',
|
'text-[10px] text-muted-foreground group'
|
||||||
'justify-between'
|
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
{tokenCount !== undefined && (
|
|
||||||
<span>{formatMessage({ id: 'cliMonitor.tokens' }, { count: tokenCount.toLocaleString() })}</span>
|
|
||||||
)}
|
|
||||||
{duration !== undefined && (
|
{duration !== undefined && (
|
||||||
<span>{formatMessage({ id: 'cliMonitor.duration' }, { value: formatDuration(duration) })}</span>
|
<span className="opacity-70">{formatDuration(duration)}</span>
|
||||||
|
)}
|
||||||
|
{tokenCount !== undefined && (
|
||||||
|
<span className="opacity-50 group-hover:opacity-70 transition-opacity">
|
||||||
|
{tokenCount.toLocaleString()} tok
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{modelName && <span>{formatMessage({ id: 'cliMonitor.model' }, { name: modelName })}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCopy}
|
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 ? (
|
{copied ? (
|
||||||
<>
|
<Check className="h-2.5 w-2.5" />
|
||||||
<Check className="h-3 w-3 mr-1" />
|
|
||||||
{formatMessage({ id: 'cliMonitor.copied' })}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Copy className="h-2.5 w-2.5" />
|
||||||
<Copy className="h-3 w-3 mr-1" />
|
|
||||||
{formatMessage({ id: 'cliMonitor.copy' })}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,46 +25,38 @@ export function ErrorMessage({
|
|||||||
className
|
className
|
||||||
}: ErrorMessageProps) {
|
}: ErrorMessageProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const timeString = timestamp
|
|
||||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-destructive/10 border-l-4 border-destructive rounded-r-lg overflow-hidden transition-all',
|
'bg-rose-50/60 dark:bg-rose-950/40 border-l-2 border-rose-500 dark:border-rose-400 rounded-r-lg overflow-hidden transition-all',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header - simplified */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
<AlertCircle className="h-3.5 w-3.5 text-rose-600 dark:text-rose-400 shrink-0" />
|
||||||
{timeString && (
|
<span className="text-xs font-medium text-rose-900 dark:text-rose-100">
|
||||||
<span className="text-xs text-muted-foreground shrink-0">
|
|
||||||
[{timeString}]
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-semibold text-destructive">
|
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-3 py-2 bg-destructive/5">
|
<div className="px-2.5 py-2 bg-rose-50/40 dark:bg-rose-950/30">
|
||||||
<p className="text-sm text-destructive-foreground whitespace-pre-wrap break-words">
|
<p className="text-xs text-rose-900 dark:text-rose-100 whitespace-pre-wrap break-words">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{(onRetry || onDismiss) && (
|
{(onRetry || onDismiss) && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-destructive/5">
|
<div className="flex items-center gap-2 px-2.5 py-1.5 bg-rose-50/40 dark:bg-rose-950/30">
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
className="h-8 px-3 text-xs border-destructive/30 text-destructive hover:bg-destructive/10"
|
className="h-6 px-2 text-[10px] border-rose-500/30 text-rose-700 dark:text-rose-300 hover:bg-rose-500/10"
|
||||||
>
|
>
|
||||||
{formatMessage({ id: 'cliMonitor.retry' })}
|
{formatMessage({ id: 'cliMonitor.retry' })}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -74,7 +66,7 @@ export function ErrorMessage({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDismiss}
|
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' })}
|
{formatMessage({ id: 'cliMonitor.dismiss' })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,37 +23,43 @@ export function SystemMessage({
|
|||||||
}: SystemMessageProps) {
|
}: SystemMessageProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const timeString = timestamp
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted/30 dark:bg-muted/20 border-l-2 border-info rounded-r-lg overflow-hidden transition-all',
|
'bg-slate-50/60 dark:bg-slate-950/40 border-l-2 border-slate-400 dark:border-slate-500 rounded-r-lg overflow-hidden transition-all',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
|
className="flex items-center gap-2 px-2.5 py-1.5 cursor-pointer hover:bg-slate-100/40 dark:hover:bg-slate-900/30 transition-colors group"
|
||||||
onClick={() => content && setIsExpanded(!isExpanded)}
|
onClick={() => content && setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<Info className="h-3.5 w-3.5 text-info shrink-0" />
|
<Info className="h-3 w-3 text-slate-500 dark:text-slate-400 shrink-0" />
|
||||||
<span className="text-xs text-muted-foreground shrink-0">
|
<span className="text-xs font-medium text-foreground truncate flex-1">
|
||||||
[{timeString}]
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-foreground truncate flex-1">
|
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
{timestamp && (
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
{timeString}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{metadata && (
|
{metadata && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
{metadata}
|
{metadata}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{content && (
|
{content && (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
'h-3 w-3 text-muted-foreground transition-transform shrink-0',
|
||||||
isExpanded && 'rotate-90'
|
isExpanded && 'rotate-90'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -62,7 +68,7 @@ export function SystemMessage({
|
|||||||
|
|
||||||
{/* Expandable Content */}
|
{/* Expandable Content */}
|
||||||
{isExpanded && content && (
|
{isExpanded && content && (
|
||||||
<div className="px-3 py-2 bg-muted/20 border-t border-border/50">
|
<div className="px-2.5 py-2 bg-slate-50/40 dark:bg-slate-950/30 border-t border-slate-200/30 dark:border-slate-800/30">
|
||||||
<div className="text-xs text-muted-foreground whitespace-pre-wrap break-words">
|
<div className="text-xs text-muted-foreground whitespace-pre-wrap break-words">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ export function UserMessage({
|
|||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const timeString = timestamp
|
|
||||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Auto-reset copied state
|
// Auto-reset copied state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,51 +43,45 @@ export function UserMessage({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-blue-50/50 dark:bg-blue-950/30 border-l-4 border-blue-500 rounded-r-lg overflow-hidden transition-all',
|
'bg-sky-50/60 dark:bg-sky-950/40 border-l-2 border-sky-500 dark:border-sky-400 rounded-r-lg overflow-hidden transition-all',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header - simplified */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-blue-100/50 dark:hover:bg-blue-900/30 transition-colors',
|
'flex items-center gap-2 px-2.5 py-1.5 cursor-pointer hover:bg-sky-100/40 dark:hover:bg-sky-900/30 transition-colors',
|
||||||
'group'
|
'group'
|
||||||
)}
|
)}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
<User className="h-3.5 w-3.5 text-sky-600 dark:text-sky-400 shrink-0" />
|
||||||
<span className="text-sm font-semibold text-blue-900 dark:text-blue-100">
|
<span className="text-xs font-medium text-sky-900 dark:text-sky-100">
|
||||||
{formatMessage({ id: 'cliMonitor.user' })}
|
{formatMessage({ id: 'cliMonitor.user' })}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-3.5 w-3.5 text-muted-foreground transition-transform ml-auto',
|
'h-3 w-3 text-muted-foreground transition-transform ml-auto',
|
||||||
!isExpanded && '-rotate-90'
|
!isExpanded && '-rotate-90'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{timeString && (
|
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
|
||||||
[{timeString}]
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-2 bg-blue-50/30 dark:bg-blue-950/20">
|
<div className="px-2.5 py-2 bg-sky-50/40 dark:bg-sky-950/30">
|
||||||
<div className="bg-white/50 dark:bg-black/20 rounded border border-blue-200/50 dark:border-blue-800/50 p-3">
|
<div className="bg-white/60 dark:bg-black/30 rounded border border-sky-200/40 dark:border-sky-800/30 p-2.5">
|
||||||
<pre className="text-sm text-foreground whitespace-pre-wrap break-words font-sans">
|
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed">
|
||||||
{content}
|
{content}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions - simplified */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-3 py-1.5 bg-blue-50/30 dark:bg-blue-950/20',
|
'flex items-center justify-end gap-1.5 px-2.5 py-1 bg-sky-50/40 dark:bg-sky-950/30 group',
|
||||||
'justify-end'
|
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -98,18 +89,12 @@ export function UserMessage({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCopy}
|
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 ? (
|
{copied ? (
|
||||||
<>
|
<Check className="h-2.5 w-2.5" />
|
||||||
<Check className="h-3 w-3 mr-1" />
|
|
||||||
{formatMessage({ id: 'cliMonitor.copied' })}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Copy className="h-2.5 w-2.5" />
|
||||||
<Copy className="h-3 w-3 mr-1" />
|
|
||||||
{formatMessage({ id: 'cliMonitor.copy' })}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{onViewRaw && (
|
{onViewRaw && (
|
||||||
@@ -117,10 +102,10 @@ export function UserMessage({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onViewRaw}
|
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' })}
|
{formatMessage({ id: 'cliMonitor.rawJson' })}
|
||||||
<ChevronDown className="h-3 w-3 ml-1" />
|
<ChevronDown className="h-2.5 w-2.5 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hook
|
|||||||
// New components for Tab + JSON Cards
|
// New components for Tab + JSON Cards
|
||||||
import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab';
|
import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab';
|
||||||
import { OutputLine } from './CliStreamMonitor/components/OutputLine';
|
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 ==========
|
// ========== Types for CLI WebSocket Messages ==========
|
||||||
|
|
||||||
@@ -74,6 +77,73 @@ function formatDuration(ms: number): string {
|
|||||||
return `${hours}h ${remainingMinutes}m`;
|
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 (
|
||||||
|
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background ${borderColor}`}>
|
||||||
|
<div className="pr-6">
|
||||||
|
{isMarkdown ? (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{contentToRender}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed">
|
||||||
|
{contentToRender}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Component ==========
|
// ========== Component ==========
|
||||||
|
|
||||||
export interface CliStreamMonitorProps {
|
export interface CliStreamMonitorProps {
|
||||||
@@ -411,13 +481,17 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filteredOutput.map((line, index) => (
|
{(() => {
|
||||||
<OutputLine
|
// Group output lines by type
|
||||||
key={`${line.timestamp}-${index}`}
|
const groupedOutput = groupOutputLines(filteredOutput);
|
||||||
line={line}
|
return groupedOutput.map((group, groupIndex) => (
|
||||||
onCopy={(content) => navigator.clipboard.writeText(content)}
|
<OutputGroupRenderer
|
||||||
/>
|
key={`group-${group.type}-${groupIndex}`}
|
||||||
))}
|
group={group}
|
||||||
|
onCopy={(content) => navigator.clipboard.writeText(content)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
<div ref={logsEndRef} />
|
<div ref={logsEndRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
MiniMap,
|
MiniMap,
|
||||||
Controls,
|
Controls,
|
||||||
Background,
|
Background,
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
type Node,
|
type Node,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import { CheckCircle, Circle, Loader2 } from 'lucide-react';
|
||||||
import type { FlowControl } from '@/lib/api';
|
import type { FlowControl } from '@/lib/api';
|
||||||
|
|
||||||
// Custom node types
|
// Custom node types
|
||||||
@@ -27,39 +30,87 @@ interface FlowchartNodeData extends Record<string, unknown> {
|
|||||||
output?: string;
|
output?: string;
|
||||||
type: 'pre-analysis' | 'implementation' | 'section';
|
type: 'pre-analysis' | 'implementation' | 'section';
|
||||||
dependsOn?: string[];
|
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 <CheckCircle className={`${className} text-green-500`} />;
|
||||||
|
case 'in_progress':
|
||||||
|
return <Loader2 className={`${className} text-amber-500 animate-spin`} />;
|
||||||
|
case 'blocked':
|
||||||
|
return <Circle className={`${className} text-red-500`} />;
|
||||||
|
case 'skipped':
|
||||||
|
return <Circle className={`${className} text-gray-400`} />;
|
||||||
|
default:
|
||||||
|
return <Circle className={`${className} text-gray-300`} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Custom node component
|
// Custom node component
|
||||||
const CustomNode: React.FC<{ data: FlowchartNodeData }> = ({ data }) => {
|
const CustomNode: React.FC<{ data: FlowchartNodeData }> = ({ data }) => {
|
||||||
const isPreAnalysis = data.type === 'pre-analysis';
|
const isPreAnalysis = data.type === 'pre-analysis';
|
||||||
const isSection = data.type === 'section';
|
const isSection = data.type === 'section';
|
||||||
|
const isCompleted = data.status === 'completed';
|
||||||
|
const isInProgress = data.status === 'in_progress';
|
||||||
|
|
||||||
if (isSection) {
|
if (isSection) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-2 bg-muted rounded border-2 border-border">
|
<div className="px-4 py-2 bg-muted rounded border-2 border-border relative">
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||||
<span className="text-sm font-semibold text-foreground">{data.label}</span>
|
<span className="text-sm font-semibold text-foreground">{data.label}</span>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-3 rounded-lg border-2 shadow-sm min-w-[280px] max-w-[400px] ${
|
className={`px-4 py-3 rounded-lg border-2 shadow-sm min-w-[280px] max-w-[400px] relative ${bgClass}`}
|
||||||
isPreAnalysis
|
|
||||||
? 'bg-amber-50 border-amber-500 dark:bg-amber-950/30'
|
|
||||||
: 'bg-blue-50 border-blue-500 dark:bg-blue-950/30'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
{/* Top handle for incoming edges */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-3 !h-3 !-top-1.5"
|
||||||
|
style={{ background: nodeColor, border: `2px solid ${nodeColor}` }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span
|
<span
|
||||||
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${stepBgClass}`}
|
||||||
isPreAnalysis ? 'bg-amber-500 text-white' : 'bg-blue-500 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{data.step}
|
{isCompleted ? <CheckCircle className="h-4 w-4" /> : data.step}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-semibold text-foreground">{data.label}</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm font-semibold ${isCompleted ? 'text-green-700 dark:text-green-400' : 'text-foreground'}`}>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
{data.status && data.status !== 'pending' && (
|
||||||
|
<StatusIcon status={data.status} className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{data.description && (
|
{data.description && (
|
||||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">{data.description}</div>
|
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">{data.description}</div>
|
||||||
)}
|
)}
|
||||||
@@ -70,6 +121,14 @@ const CustomNode: React.FC<{ data: FlowchartNodeData }> = ({ data }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom handle for outgoing edges */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
className="!w-3 !h-3 !-bottom-1.5"
|
||||||
|
style={{ background: nodeColor, border: `2px solid ${nodeColor}` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -136,6 +195,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
|||||||
target: nodeId,
|
target: nodeId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
animated: false,
|
||||||
|
style: { stroke: '#f59e0b', strokeWidth: 2 },
|
||||||
|
markerEnd: { type: 'arrowclosed' as const, color: '#f59e0b' },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
initialEdges.push({
|
initialEdges.push({
|
||||||
@@ -144,6 +205,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
|||||||
target: nodeId,
|
target: nodeId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
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,
|
target: implSectionId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: true,
|
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
|
// Handle both string and ImplementationStep types
|
||||||
const isString = typeof step === 'string';
|
const isString = typeof step === 'string';
|
||||||
const label = isString ? step : (step.title || `Step ${step.step}`);
|
|
||||||
const description = isString ? undefined : step.description;
|
// Extract just the number from strings like "Step 1", "step1", etc.
|
||||||
const stepNumber = isString ? (idx + 1) : step.step;
|
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}`);
|
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({
|
initialNodes.push({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
@@ -201,6 +306,7 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
|||||||
step: stepNumber,
|
step: stepNumber,
|
||||||
type: 'implementation' as const,
|
type: 'implementation' as const,
|
||||||
dependsOn,
|
dependsOn,
|
||||||
|
status: stepStatus,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,6 +318,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
|||||||
target: nodeId,
|
target: nodeId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
animated: false,
|
||||||
|
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||||
|
markerEnd: { type: 'arrowclosed' as const, color: '#3b82f6' },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Sequential edge with styled connection
|
// Sequential edge with styled connection
|
||||||
@@ -221,7 +329,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
|||||||
target: nodeId,
|
target: nodeId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
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,
|
target: nodeId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
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) => {
|
nodeColor={(node) => {
|
||||||
const data = node.data as FlowchartNodeData;
|
const data = node.data as FlowchartNodeData;
|
||||||
if (data.type === 'section') return '#9ca3af';
|
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';
|
if (data.type === 'pre-analysis') return '#f59e0b';
|
||||||
return '#3b82f6';
|
return '#3b82f6';
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Archive,
|
Archive,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { SessionMetadata } from '@/types/store';
|
import type { SessionMetadata } from '@/types/store';
|
||||||
|
|
||||||
@@ -175,17 +178,12 @@ export function SessionCard({
|
|||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
{/* Header */}
|
{/* Header - Session ID as title */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-card-foreground truncate">
|
<h3 className="font-bold text-card-foreground text-sm tracking-wide uppercase truncate">
|
||||||
{session.title || session.session_id}
|
{session.session_id}
|
||||||
</h3>
|
</h3>
|
||||||
{session.title && session.title !== session.session_id && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
|
||||||
{session.session_id}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||||
@@ -231,8 +229,15 @@ export function SessionCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta info */}
|
{/* Title as description */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
{session.title && (
|
||||||
|
<p className="text-sm text-foreground line-clamp-2 mb-3">
|
||||||
|
{session.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta info - enriched */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
{formatDate(session.created_at)}
|
{formatDate(session.created_at)}
|
||||||
@@ -241,6 +246,18 @@ export function SessionCard({
|
|||||||
<ListChecks className="h-3.5 w-3.5" />
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
{progress.total} {formatMessage({ id: 'sessions.card.tasks' })}
|
{progress.total} {formatMessage({ id: 'sessions.card.tasks' })}
|
||||||
</span>
|
</span>
|
||||||
|
{progress.total > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||||
|
{progress.completed} {formatMessage({ id: 'sessions.card.completed' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.updated_at && session.updated_at !== session.created_at && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{formatMessage({ id: 'sessions.card.updated' })}: {formatDate(session.updated_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar (only show if not planning and has tasks) */}
|
{/* Progress bar (only show if not planning and has tasks) */}
|
||||||
@@ -254,16 +271,19 @@ export function SessionCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-300"
|
className={cn(
|
||||||
|
"h-full transition-all duration-300",
|
||||||
|
progress.percentage === 100 ? "bg-success" : "bg-primary"
|
||||||
|
)}
|
||||||
style={{ width: `${progress.percentage}%` }}
|
style={{ width: `${progress.percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description (if exists) */}
|
{/* Description (if exists and different from title) */}
|
||||||
{session.description && (
|
{session.description && session.description !== session.title && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
<p className="mt-2 text-xs text-muted-foreground line-clamp-2 italic">
|
||||||
{session.description}
|
{session.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
|||||||
<div className="flex items-start justify-between p-6 border-b border-border bg-card">
|
<div className="flex items-start justify-between p-6 border-b border-border bg-card">
|
||||||
<div className="flex-1 min-w-0 mr-4">
|
<div className="flex-1 min-w-0 mr-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">{taskId}</span>
|
||||||
<Badge variant={statusConfig.variant} className="gap-1">
|
<Badge variant={statusConfig.variant} className="gap-1">
|
||||||
<StatusIcon className="h-3 w-3" />
|
<StatusIcon className="h-3 w-3" />
|
||||||
{formatMessage({ id: statusConfig.label })}
|
{formatMessage({ id: statusConfig.label })}
|
||||||
@@ -188,13 +188,14 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
|||||||
|
|
||||||
{/* Tab Content (scrollable) */}
|
{/* Tab Content (scrollable) */}
|
||||||
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
|
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
|
||||||
{/* Overview Tab */}
|
{/* Overview Tab - Rich display matching JS version */}
|
||||||
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
|
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* Description */}
|
{/* Description Section */}
|
||||||
{taskDescription && (
|
{taskDescription && (
|
||||||
<div>
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||||
|
<span>📝</span>
|
||||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.description' })}
|
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.description' })}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
@@ -203,30 +204,94 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scope Section */}
|
||||||
|
{(task as LiteTask).meta?.scope && (
|
||||||
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||||
|
<span>📁</span>
|
||||||
|
Scope
|
||||||
|
</h3>
|
||||||
|
<div className="pl-3 border-l-2 border-primary">
|
||||||
|
<code className="text-sm text-foreground">{(task as LiteTask).meta?.scope}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Acceptance Criteria Section */}
|
||||||
|
{(task as LiteTask).context?.acceptance && (task as LiteTask).context!.acceptance!.length > 0 && (
|
||||||
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span>✅</span>
|
||||||
|
{formatMessage({ id: 'liteTasks.acceptanceCriteria' })}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(task as LiteTask).context!.acceptance!.map((criterion, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<span className="text-muted-foreground mt-0.5">○</span>
|
||||||
|
<span className="text-sm text-foreground">{criterion}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Focus Paths / Reference Section */}
|
||||||
|
{(task as LiteTask).context?.focus_paths && (task as LiteTask).context!.focus_paths!.length > 0 && (
|
||||||
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span>📚</span>
|
||||||
|
{formatMessage({ id: 'liteTasks.focusPaths' })}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(task as LiteTask).context!.focus_paths!.map((path, i) => (
|
||||||
|
<code key={i} className="block text-xs bg-muted px-3 py-1.5 rounded text-foreground font-mono">
|
||||||
|
{path}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dependencies Section */}
|
||||||
|
{(task as LiteTask).context?.depends_on && (task as LiteTask).context!.depends_on!.length > 0 && (
|
||||||
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span>🔗</span>
|
||||||
|
{formatMessage({ id: 'liteTasks.dependsOn' })}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(task as LiteTask).context!.depends_on!.map((dep, i) => (
|
||||||
|
<Badge key={i} variant="secondary">{dep}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pre-analysis Steps */}
|
{/* Pre-analysis Steps */}
|
||||||
{flowControl?.pre_analysis && flowControl.pre_analysis.length > 0 && (
|
{flowControl?.pre_analysis && flowControl.pre_analysis.length > 0 && (
|
||||||
<div>
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span>🔍</span>
|
||||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.preAnalysis' })}
|
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.preAnalysis' })}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{flowControl.pre_analysis.map((step, index) => (
|
{flowControl.pre_analysis.map((step, index) => (
|
||||||
<div key={index} className="p-3 bg-card rounded-md border border-border shadow-sm">
|
<div key={index} className="flex items-start gap-3">
|
||||||
<div className="flex items-start gap-2">
|
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
{index + 1}
|
||||||
{index + 1}
|
</span>
|
||||||
</span>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<p className="text-sm font-medium text-foreground">{step.step || step.action}</p>
|
||||||
<p className="text-sm font-medium text-foreground">{step.step}</p>
|
{step.action && step.action !== step.step && (
|
||||||
<p className="text-xs text-muted-foreground mt-1">{step.action}</p>
|
<p className="text-xs text-muted-foreground mt-1">{step.action}</p>
|
||||||
{step.commands && step.commands.length > 0 && (
|
)}
|
||||||
<div className="mt-2">
|
{step.commands && step.commands.length > 0 && (
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded border">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{step.commands.join('; ')}
|
{step.commands.map((cmd, i) => (
|
||||||
</code>
|
<code key={i} className="text-xs bg-muted px-2 py-0.5 rounded">{cmd}</code>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -236,41 +301,78 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
|||||||
|
|
||||||
{/* Implementation Steps */}
|
{/* Implementation Steps */}
|
||||||
{flowControl?.implementation_approach && flowControl.implementation_approach.length > 0 && (
|
{flowControl?.implementation_approach && flowControl.implementation_approach.length > 0 && (
|
||||||
<div>
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span>📋</span>
|
||||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.implementationSteps' })}
|
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.implementationSteps' })}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<ol className="space-y-3">
|
||||||
{flowControl.implementation_approach.map((step, index) => {
|
{flowControl.implementation_approach.map((step, index) => {
|
||||||
const isString = typeof step === 'string';
|
const isString = typeof step === 'string';
|
||||||
const title = isString ? step : (step.title || `Step ${step.step || index + 1}`);
|
// Extract just the number from strings like "Step 1", "step1", etc.
|
||||||
const description = isString ? undefined : step.description;
|
const rawStep = isString ? (index + 1) : (step.step || index + 1);
|
||||||
const stepNumber = 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 (
|
return (
|
||||||
<div key={index} className="p-3 bg-card rounded-md border border-border shadow-sm">
|
<li key={index} className="flex items-start gap-3">
|
||||||
<div className="flex items-start gap-2">
|
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
{stepNumber}
|
||||||
{stepNumber}
|
</span>
|
||||||
</span>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<p className="text-sm font-medium text-foreground">{stepTitle}</p>
|
||||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
{stepDesc && (
|
||||||
{description && (
|
<p className="text-xs text-muted-foreground mt-1">{stepDesc}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!taskDescription &&
|
{!taskDescription &&
|
||||||
(!flowControl?.pre_analysis || flowControl.pre_analysis.length === 0) &&
|
!(task as LiteTask).meta?.scope &&
|
||||||
(!flowControl?.implementation_approach || flowControl.implementation_approach.length === 0) && (
|
!((task as LiteTask).context?.acceptance?.length) &&
|
||||||
|
!((task as LiteTask).context?.focus_paths?.length) &&
|
||||||
|
!(flowControl?.pre_analysis?.length) &&
|
||||||
|
!(flowControl?.implementation_approach?.length) && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -33,10 +33,13 @@ export interface BadgeProps
|
|||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
return (
|
({ className, variant, ...props }, ref) => {
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
return (
|
||||||
);
|
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Badge.displayName = "Badge";
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -95,3 +95,18 @@ export {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
} from "./Collapsible";
|
} from "./Collapsible";
|
||||||
|
|
||||||
|
// AlertDialog (Radix)
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
} from "./AlertDialog";
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Workspace Selector Component
|
// 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 { useState, useCallback, useRef } from 'react';
|
||||||
import { ChevronDown, X } from 'lucide-react';
|
import { ChevronDown, X, FolderOpen, Check } from 'lucide-react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -89,6 +89,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
|||||||
const [isBrowseOpen, setIsBrowseOpen] = useState(false);
|
const [isBrowseOpen, setIsBrowseOpen] = useState(false);
|
||||||
const [manualPath, setManualPath] = useState('');
|
const [manualPath, setManualPath] = useState('');
|
||||||
|
|
||||||
|
// Hidden file input for folder selection
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle path selection from dropdown
|
* 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);
|
setIsDropdownOpen(false);
|
||||||
|
// Trigger the hidden file input click
|
||||||
// Try to use Electron/Electron-Tauri file dialog API if available
|
folderInputRef.current?.click();
|
||||||
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);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle folder selection from file input
|
||||||
|
*/
|
||||||
|
const handleFolderSelect = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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
|
* Handle manual path submission
|
||||||
*/
|
*/
|
||||||
@@ -214,18 +224,23 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
|||||||
key={path}
|
key={path}
|
||||||
onClick={() => handleSelectPath(path)}
|
onClick={() => handleSelectPath(path)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 cursor-pointer group',
|
'flex items-center gap-2 cursor-pointer group/path-item pr-8',
|
||||||
isCurrent && 'bg-accent'
|
isCurrent && 'bg-accent/50'
|
||||||
)}
|
)}
|
||||||
title={path}
|
title={path}
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate">{truncatedItemPath}</span>
|
<span className={cn(
|
||||||
|
'flex-1 truncate',
|
||||||
|
isCurrent && 'font-medium'
|
||||||
|
)}>
|
||||||
|
{truncatedItemPath}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Delete button for non-current paths */}
|
{/* Delete button for non-current paths */}
|
||||||
{!isCurrent && (
|
{!isCurrent && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleRemovePath(e, path)}
|
onClick={(e) => handleRemovePath(e, path)}
|
||||||
className="opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground rounded p-0.5 transition-opacity"
|
className="absolute right-2 opacity-0 group-hover/path-item:opacity-100 hover:bg-destructive/10 hover:text-destructive rounded p-0.5 transition-all"
|
||||||
aria-label={formatMessage({ id: 'workspace.selector.removePath' })}
|
aria-label={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||||
title={formatMessage({ id: 'workspace.selector.removePath' })}
|
title={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||||
>
|
>
|
||||||
@@ -233,10 +248,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Check icon for current workspace */}
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span className="text-xs text-primary">
|
<Check className="h-4 w-4 text-emerald-500 absolute right-2" />
|
||||||
{formatMessage({ id: 'workspace.selector.current' })}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
@@ -245,16 +259,49 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
|||||||
|
|
||||||
{recentPaths.length > 0 && <DropdownMenuSeparator />}
|
{recentPaths.length > 0 && <DropdownMenuSeparator />}
|
||||||
|
|
||||||
{/* Browse button to open manual path dialog */}
|
{/* Browse button to open folder selector */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={handleBrowseFolder}
|
onClick={handleBrowseFolder}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer gap-2"
|
||||||
>
|
>
|
||||||
{formatMessage({ id: 'workspace.selector.browse' })}
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatMessage({ id: 'workspace.selector.browse' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'workspace.selector.browseHint' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{/* Manual path input option */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
setIsBrowseOpen(true);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer gap-2"
|
||||||
|
>
|
||||||
|
<span className="flex-1">
|
||||||
|
{formatMessage({ id: 'workspace.selector.manualPath' })}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Hidden file input for folder selection */}
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type="file"
|
||||||
|
webkitdirectory=""
|
||||||
|
directory=""
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFolderSelect}
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Manual path input dialog */}
|
{/* Manual path input dialog */}
|
||||||
<Dialog open={isBrowseOpen} onOpenChange={setIsBrowseOpen}>
|
<Dialog open={isBrowseOpen} onOpenChange={setIsBrowseOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -104,12 +104,14 @@ export type {
|
|||||||
export {
|
export {
|
||||||
useCommands,
|
useCommands,
|
||||||
useCommandSearch,
|
useCommandSearch,
|
||||||
|
useCommandMutations,
|
||||||
commandsKeys,
|
commandsKeys,
|
||||||
} from './useCommands';
|
} from './useCommands';
|
||||||
export type {
|
export type {
|
||||||
CommandsFilter,
|
CommandsFilter,
|
||||||
UseCommandsOptions,
|
UseCommandsOptions,
|
||||||
UseCommandsReturn,
|
UseCommandsReturn,
|
||||||
|
UseCommandMutationsReturn,
|
||||||
} from './useCommands';
|
} from './useCommands';
|
||||||
|
|
||||||
// ========== Memory ==========
|
// ========== Memory ==========
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// TanStack Query hooks for commands management
|
// TanStack Query hooks for commands management
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
fetchCommands,
|
fetchCommands,
|
||||||
|
toggleCommand as toggleCommandApi,
|
||||||
|
toggleCommandGroup as toggleCommandGroupApi,
|
||||||
type Command,
|
type Command,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
@@ -24,6 +26,9 @@ export interface CommandsFilter {
|
|||||||
search?: string;
|
search?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
source?: Command['source'];
|
source?: Command['source'];
|
||||||
|
group?: string;
|
||||||
|
location?: 'project' | 'user';
|
||||||
|
showDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
@@ -36,7 +41,11 @@ export interface UseCommandsReturn {
|
|||||||
commands: Command[];
|
commands: Command[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
commandsByCategory: Record<string, Command[]>;
|
commandsByCategory: Record<string, Command[]>;
|
||||||
|
groupedCommands: Record<string, Command[]>;
|
||||||
|
groups: string[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
enabledCount: number;
|
||||||
|
disabledCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
@@ -47,6 +56,40 @@ export interface UseCommandsReturn {
|
|||||||
/**
|
/**
|
||||||
* Hook for fetching and filtering commands
|
* Hook for fetching and filtering commands
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface UseCommandMutationsReturn {
|
||||||
|
toggleCommand: (name: string, enabled: boolean, location: 'project' | 'user') => Promise<any>;
|
||||||
|
toggleGroup: (groupName: string, enable: boolean, location: 'project' | 'user') => Promise<any>;
|
||||||
|
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 {
|
export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn {
|
||||||
const { filter, staleTime = STALE_TIME, enabled = true } = options;
|
const { filter, staleTime = STALE_TIME, enabled = true } = options;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -85,6 +128,18 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
|
|||||||
commands = commands.filter((c) => c.source === filter.source);
|
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;
|
return commands;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -101,6 +156,21 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
|
|||||||
commandsByCategory[category].push(command);
|
commandsByCategory[category].push(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group by group
|
||||||
|
const groupedCommands: Record<string, Command[]> = {};
|
||||||
|
const groups = new Set<string>();
|
||||||
|
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 () => {
|
const refetch = async () => {
|
||||||
await query.refetch();
|
await query.refetch();
|
||||||
};
|
};
|
||||||
@@ -113,6 +183,10 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
|
|||||||
commands: filteredCommands,
|
commands: filteredCommands,
|
||||||
categories: Array.from(categories).sort(),
|
categories: Array.from(categories).sort(),
|
||||||
commandsByCategory,
|
commandsByCategory,
|
||||||
|
groupedCommands,
|
||||||
|
groups: Array.from(groups).sort(),
|
||||||
|
enabledCount,
|
||||||
|
disabledCount,
|
||||||
totalCount: allCommands.length,
|
totalCount: allCommands.length,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
isFetching: query.isFetching,
|
isFetching: query.isFetching,
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { fetchSessionDetail } from '../lib/api';
|
import { fetchSessionDetail } from '../lib/api';
|
||||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
|
||||||
// Query key factory
|
// Query key factory - include projectPath as part of the key
|
||||||
export const sessionDetailKeys = {
|
export const sessionDetailKeys = {
|
||||||
all: ['sessionDetail'] as const,
|
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
|
// Default stale time: 30 seconds
|
||||||
@@ -38,7 +38,7 @@ export function useSessionDetail(sessionId: string, options: UseSessionDetailOpt
|
|||||||
const queryEnabled = enabled && !!sessionId && !!projectPath;
|
const queryEnabled = enabled && !!sessionId && !!projectPath;
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: sessionDetailKeys.detail(sessionId),
|
queryKey: sessionDetailKeys.detail(sessionId, projectPath),
|
||||||
queryFn: () => fetchSessionDetail(sessionId, projectPath),
|
queryFn: () => fetchSessionDetail(sessionId, projectPath),
|
||||||
staleTime,
|
staleTime,
|
||||||
enabled: queryEnabled,
|
enabled: queryEnabled,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
fetchSkills,
|
fetchSkills,
|
||||||
toggleSkill,
|
enableSkill,
|
||||||
|
disableSkill,
|
||||||
type Skill,
|
type Skill,
|
||||||
type SkillsResponse,
|
type SkillsResponse,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
@@ -28,6 +29,7 @@ export interface SkillsFilter {
|
|||||||
category?: string;
|
category?: string;
|
||||||
source?: Skill['source'];
|
source?: Skill['source'];
|
||||||
enabledOnly?: boolean;
|
enabledOnly?: boolean;
|
||||||
|
location?: 'project' | 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSkillsOptions {
|
export interface UseSkillsOptions {
|
||||||
@@ -43,6 +45,8 @@ export interface UseSkillsReturn {
|
|||||||
skillsByCategory: Record<string, Skill[]>;
|
skillsByCategory: Record<string, Skill[]>;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
enabledCount: number;
|
enabledCount: number;
|
||||||
|
projectSkills: Skill[];
|
||||||
|
userSkills: Skill[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
@@ -68,10 +72,18 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
|
|||||||
|
|
||||||
const allSkills = query.data?.skills ?? [];
|
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
|
// Apply filters
|
||||||
const filteredSkills = (() => {
|
const filteredSkills = (() => {
|
||||||
let skills = allSkills;
|
let skills = allSkills;
|
||||||
|
|
||||||
|
if (filter?.location) {
|
||||||
|
skills = skills.filter((s) => s.location === filter.location);
|
||||||
|
}
|
||||||
|
|
||||||
if (filter?.search) {
|
if (filter?.search) {
|
||||||
const searchLower = filter.search.toLowerCase();
|
const searchLower = filter.search.toLowerCase();
|
||||||
skills = skills.filter(
|
skills = skills.filter(
|
||||||
@@ -129,6 +141,8 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
|
|||||||
skillsByCategory,
|
skillsByCategory,
|
||||||
totalCount: allSkills.length,
|
totalCount: allSkills.length,
|
||||||
enabledCount: enabledSkills.length,
|
enabledCount: enabledSkills.length,
|
||||||
|
projectSkills,
|
||||||
|
userSkills,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
isFetching: query.isFetching,
|
isFetching: query.isFetching,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
@@ -140,7 +154,7 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
|
|||||||
// ========== Mutations ==========
|
// ========== Mutations ==========
|
||||||
|
|
||||||
export interface UseToggleSkillReturn {
|
export interface UseToggleSkillReturn {
|
||||||
toggleSkill: (skillName: string, enabled: boolean) => Promise<Skill>;
|
toggleSkill: (skillName: string, enabled: boolean, location: 'project' | 'user') => Promise<Skill>;
|
||||||
isToggling: boolean;
|
isToggling: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
@@ -150,8 +164,10 @@ export function useToggleSkill(): UseToggleSkillReturn {
|
|||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: ({ skillName, enabled }: { skillName: string; enabled: boolean }) =>
|
mutationFn: ({ skillName, enabled, location }: { skillName: string; enabled: boolean; location: 'project' | 'user' }) =>
|
||||||
toggleSkill(skillName, enabled),
|
enabled
|
||||||
|
? enableSkill(skillName, location, projectPath)
|
||||||
|
: disableSkill(skillName, location, projectPath),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate to ensure sync with server
|
// Invalidate to ensure sync with server
|
||||||
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.skills(projectPath) : ['skills'] });
|
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.skills(projectPath) : ['skills'] });
|
||||||
@@ -159,7 +175,7 @@ export function useToggleSkill(): UseToggleSkillReturn {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toggleSkill: (skillName, enabled) => mutation.mutateAsync({ skillName, enabled }),
|
toggleSkill: (skillName, enabled, location) => mutation.mutateAsync({ skillName, enabled, location }),
|
||||||
isToggling: mutation.isPending,
|
isToggling: mutation.isPending,
|
||||||
error: mutation.error,
|
error: mutation.error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
--border: 220 20% 88%;
|
--border: 220 20% 88%;
|
||||||
--text: 220 30% 15%;
|
--text: 220 30% 15%;
|
||||||
--text-secondary: 220 15% 45%;
|
--text-secondary: 220 15% 45%;
|
||||||
--accent: 220 90% 56%;
|
--accent: 220 60% 65%;
|
||||||
|
|
||||||
/* Legacy variables for backward compatibility */
|
/* Legacy variables for backward compatibility */
|
||||||
--background: var(--bg);
|
--background: var(--bg);
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
--card-foreground: var(--text);
|
--card-foreground: var(--text);
|
||||||
--primary: var(--accent);
|
--primary: var(--accent);
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--primary-light: 220 90% 95%;
|
--primary-light: 220 60% 92%;
|
||||||
--secondary: 220 60% 65%;
|
--secondary: 220 60% 65%;
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0 0% 100%;
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
--success-light: 142 76% 90%;
|
--success-light: 142 76% 90%;
|
||||||
--warning: 38 92% 50%;
|
--warning: 38 92% 50%;
|
||||||
--warning-light: 48 96% 89%;
|
--warning-light: 48 96% 89%;
|
||||||
--info: 210 80% 55%;
|
--info: 220 60% 60%;
|
||||||
--info-light: 210 80% 92%;
|
--info-light: 210 80% 92%;
|
||||||
--indigo: 239 65% 60%;
|
--indigo: 239 65% 60%;
|
||||||
--indigo-light: 239 65% 92%;
|
--indigo-light: 239 65% 92%;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
--border: 220 20% 22%;
|
--border: 220 20% 22%;
|
||||||
--text: 220 20% 90%;
|
--text: 220 20% 90%;
|
||||||
--text-secondary: 220 15% 60%;
|
--text-secondary: 220 15% 60%;
|
||||||
--accent: 220 90% 60%;
|
--accent: 220 60% 65%;
|
||||||
|
|
||||||
/* Legacy variables */
|
/* Legacy variables */
|
||||||
--background: var(--bg);
|
--background: var(--bg);
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
--card-foreground: var(--text);
|
--card-foreground: var(--text);
|
||||||
--primary: var(--accent);
|
--primary: var(--accent);
|
||||||
--primary-foreground: 220 30% 10%;
|
--primary-foreground: 220 30% 10%;
|
||||||
--primary-light: 220 70% 25%;
|
--primary-light: 220 60% 30%;
|
||||||
--secondary: 220 60% 60%;
|
--secondary: 220 60% 60%;
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
--accent-foreground: 220 30% 10%;
|
--accent-foreground: 220 30% 10%;
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
--success-light: 142 50% 20%;
|
--success-light: 142 50% 20%;
|
||||||
--warning: 38 85% 45%;
|
--warning: 38 85% 45%;
|
||||||
--warning-light: 40 50% 20%;
|
--warning-light: 40 50% 20%;
|
||||||
--info: 210 75% 50%;
|
--info: 220 60% 55%;
|
||||||
--info-light: 210 50% 20%;
|
--info-light: 210 50% 20%;
|
||||||
--indigo: 239 60% 55%;
|
--indigo: 239 60% 55%;
|
||||||
--indigo-light: 239 40% 20%;
|
--indigo-light: 239 40% 20%;
|
||||||
@@ -346,7 +346,7 @@
|
|||||||
--border: var(--border, 220 20% 22%);
|
--border: var(--border, 220 20% 22%);
|
||||||
--text: var(--text, 220 20% 90%);
|
--text: var(--text, 220 20% 90%);
|
||||||
--text-secondary: var(--text-secondary, 220 15% 60%);
|
--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 */
|
/* Apply dark-blue theme as fallback */
|
||||||
--background: 220 30% 10%;
|
--background: 220 30% 10%;
|
||||||
@@ -355,13 +355,13 @@
|
|||||||
--card-foreground: 220 20% 90%;
|
--card-foreground: 220 20% 90%;
|
||||||
--border: 220 20% 22%;
|
--border: 220 20% 22%;
|
||||||
--input: 220 20% 22%;
|
--input: 220 20% 22%;
|
||||||
--ring: 220 90% 60%;
|
--ring: 220 60% 65%;
|
||||||
--primary: 220 90% 60%;
|
--primary: 220 60% 65%;
|
||||||
--primary-foreground: 220 30% 10%;
|
--primary-foreground: 220 30% 10%;
|
||||||
--primary-light: 220 70% 25%;
|
--primary-light: 220 60% 30%;
|
||||||
--secondary: 220 60% 60%;
|
--secondary: 220 60% 60%;
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
--accent: 220 90% 60%;
|
--accent: 220 60% 65%;
|
||||||
--accent-foreground: 220 30% 10%;
|
--accent-foreground: 220 30% 10%;
|
||||||
--destructive: 8 70% 50%;
|
--destructive: 8 70% 50%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
@@ -374,7 +374,7 @@
|
|||||||
--success-light: 142 50% 20%;
|
--success-light: 142 50% 20%;
|
||||||
--warning: 38 85% 45%;
|
--warning: 38 85% 45%;
|
||||||
--warning-light: 40 50% 20%;
|
--warning-light: 40 50% 20%;
|
||||||
--info: 210 75% 50%;
|
--info: 220 60% 55%;
|
||||||
--info-light: 210 50% 20%;
|
--info-light: 210 50% 20%;
|
||||||
--indigo: 239 60% 55%;
|
--indigo: 239 60% 55%;
|
||||||
--indigo-light: 239 40% 20%;
|
--indigo-light: 239 40% 20%;
|
||||||
|
|||||||
@@ -892,6 +892,7 @@ export interface Skill {
|
|||||||
source?: 'builtin' | 'custom' | 'community';
|
source?: 'builtin' | 'custom' | 'community';
|
||||||
version?: string;
|
version?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
location?: 'project' | 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsResponse {
|
export interface SkillsResponse {
|
||||||
@@ -903,12 +904,18 @@ export interface SkillsResponse {
|
|||||||
* @param projectPath - Optional project path to filter data by workspace
|
* @param projectPath - Optional project path to filter data by workspace
|
||||||
*/
|
*/
|
||||||
export async function fetchSkills(projectPath?: string): Promise<SkillsResponse> {
|
export async function fetchSkills(projectPath?: string): Promise<SkillsResponse> {
|
||||||
|
// 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
|
// Try with project path first, fall back to global on 403/404
|
||||||
if (projectPath) {
|
if (projectPath) {
|
||||||
try {
|
try {
|
||||||
const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
|
const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
|
||||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url);
|
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 {
|
return {
|
||||||
skills: data.skills ?? allSkills,
|
skills: data.skills ?? allSkills,
|
||||||
};
|
};
|
||||||
@@ -924,19 +931,39 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
|
|||||||
}
|
}
|
||||||
// Fallback: fetch global skills
|
// Fallback: fetch global skills
|
||||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/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 {
|
return {
|
||||||
skills: data.skills ?? allSkills,
|
skills: data.skills ?? allSkills,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle skill enabled status
|
* Enable a skill
|
||||||
*/
|
*/
|
||||||
export async function toggleSkill(skillName: string, enabled: boolean): Promise<Skill> {
|
export async function enableSkill(
|
||||||
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}`, {
|
skillName: string,
|
||||||
method: 'PATCH',
|
location: 'project' | 'user',
|
||||||
body: JSON.stringify({ enabled }),
|
projectPath?: string
|
||||||
|
): Promise<Skill> {
|
||||||
|
return fetchApi<Skill>(`/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<Skill> {
|
||||||
|
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}/disable`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ location, projectPath }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,10 +977,18 @@ export interface Command {
|
|||||||
category?: string;
|
category?: string;
|
||||||
aliases?: string[];
|
aliases?: string[];
|
||||||
source?: 'builtin' | 'custom';
|
source?: 'builtin' | 'custom';
|
||||||
|
group?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
location?: 'project' | 'user';
|
||||||
|
path?: string;
|
||||||
|
relativePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandsResponse {
|
export interface CommandsResponse {
|
||||||
commands: Command[];
|
commands: Command[];
|
||||||
|
groups?: string[];
|
||||||
|
projectGroupsConfig?: Record<string, any>;
|
||||||
|
userGroupsConfig?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -965,10 +1000,20 @@ export async function fetchCommands(projectPath?: string): Promise<CommandsRespo
|
|||||||
if (projectPath) {
|
if (projectPath) {
|
||||||
try {
|
try {
|
||||||
const url = `/api/commands?path=${encodeURIComponent(projectPath)}`;
|
const url = `/api/commands?path=${encodeURIComponent(projectPath)}`;
|
||||||
const data = await fetchApi<{ commands?: Command[]; projectCommands?: Command[]; userCommands?: Command[] }>(url);
|
const data = await fetchApi<{
|
||||||
|
commands?: Command[];
|
||||||
|
projectCommands?: Command[];
|
||||||
|
userCommands?: Command[];
|
||||||
|
groups?: string[];
|
||||||
|
projectGroupsConfig?: Record<string, any>;
|
||||||
|
userGroupsConfig?: Record<string, any>;
|
||||||
|
}>(url);
|
||||||
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
||||||
return {
|
return {
|
||||||
commands: data.commands ?? allCommands,
|
commands: data.commands ?? allCommands,
|
||||||
|
groups: data.groups,
|
||||||
|
projectGroupsConfig: data.projectGroupsConfig,
|
||||||
|
userGroupsConfig: data.userGroupsConfig,
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError;
|
||||||
@@ -981,13 +1026,65 @@ export async function fetchCommands(projectPath?: string): Promise<CommandsRespo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: fetch global commands
|
// Fallback: fetch global commands
|
||||||
const data = await fetchApi<{ commands?: Command[]; projectCommands?: Command[]; userCommands?: Command[] }>('/api/commands');
|
const data = await fetchApi<{
|
||||||
|
commands?: Command[];
|
||||||
|
projectCommands?: Command[];
|
||||||
|
userCommands?: Command[];
|
||||||
|
groups?: string[];
|
||||||
|
projectGroupsConfig?: Record<string, any>;
|
||||||
|
userGroupsConfig?: Record<string, any>;
|
||||||
|
}>('/api/commands');
|
||||||
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
||||||
return {
|
return {
|
||||||
commands: data.commands ?? allCommands,
|
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<string, any>; assignments: Record<string, string> }> {
|
||||||
|
const params = new URLSearchParams({ location });
|
||||||
|
if (projectPath) params.set('path', projectPath);
|
||||||
|
return fetchApi<{ groups: Record<string, any>; assignments: Record<string, string> }>(`/api/commands/groups/config?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Memory API ==========
|
// ========== Memory API ==========
|
||||||
|
|
||||||
export interface CoreMemory {
|
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
|
// 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 sessionPath = (session as any).path || session.session_id;
|
||||||
const pathParam = projectPath || sessionPath;
|
const detailData = await fetchApi<any>(`/api/session-detail?path=${encodeURIComponent(sessionPath)}&type=all`);
|
||||||
const detailData = await fetchApi<any>(`/api/session-detail?path=${encodeURIComponent(pathParam)}&type=all`);
|
|
||||||
|
|
||||||
// Step 3: Transform the response to match SessionDetailResponse interface
|
// Step 3: Transform the response to match SessionDetailResponse interface
|
||||||
// Also check for summaries array and extract first one if summary is empty
|
// 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 || '';
|
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 {
|
return {
|
||||||
session,
|
session,
|
||||||
context: detailData.context,
|
context: transformedContext,
|
||||||
summary: finalSummary,
|
summary: finalSummary,
|
||||||
summaries: detailData.summaries,
|
summaries: detailData.summaries,
|
||||||
implPlan: detailData.implPlan,
|
implPlan: detailData.implPlan,
|
||||||
conflicts: detailData.conflicts,
|
conflicts: detailData.conflictResolution, // Backend returns 'conflictResolution', not 'conflicts'
|
||||||
review: detailData.review,
|
review: detailData.review,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1504,6 +1604,7 @@ export interface ImplementationStep {
|
|||||||
commands?: string[];
|
commands?: string[];
|
||||||
steps?: string[];
|
steps?: string[];
|
||||||
test_patterns?: string;
|
test_patterns?: string;
|
||||||
|
status?: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped';
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,12 +1649,18 @@ export interface LiteTaskSession {
|
|||||||
type: 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
type: 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
path?: string;
|
||||||
tasks?: LiteTask[];
|
tasks?: LiteTask[];
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
latestSynthesis?: {
|
latestSynthesis?: {
|
||||||
title?: string | { en?: string; zh?: string };
|
title?: string | { en?: string; zh?: string };
|
||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
diagnoses?: {
|
||||||
|
manifest?: Record<string, unknown>;
|
||||||
|
items?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
plan?: Record<string, unknown>;
|
||||||
roundCount?: number;
|
roundCount?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -1594,6 +1701,31 @@ export async function fetchLiteTaskSession(
|
|||||||
return sessions.find(s => s.id === sessionId || s.session_id === sessionId) || null;
|
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<string, unknown>;
|
||||||
|
explorations?: {
|
||||||
|
manifest?: Record<string, unknown>;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
diagnoses?: {
|
||||||
|
manifest?: Record<string, unknown>;
|
||||||
|
items?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLiteSessionContext(
|
||||||
|
sessionPath: string
|
||||||
|
): Promise<LiteSessionContext> {
|
||||||
|
const data = await fetchApi<LiteSessionContext>(
|
||||||
|
`/api/session-detail?path=${encodeURIComponent(sessionPath)}&type=context`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Review Session API ==========
|
// ========== Review Session API ==========
|
||||||
|
|
||||||
export interface ReviewFinding {
|
export interface ReviewFinding {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const COLOR_SCHEMES: ThemeOption[] = [
|
|||||||
{
|
{
|
||||||
id: 'blue',
|
id: 'blue',
|
||||||
name: '经典蓝',
|
name: '经典蓝',
|
||||||
accentColor: '#3b82f6', // blue-500
|
accentColor: '#5b8fc4', // blue-gray tone
|
||||||
description: 'Classic professional blue tone'
|
description: 'Classic professional blue tone'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"description": "Manage CLI hooks for automated workflows",
|
"description": "Manage CLI hooks for automated workflows",
|
||||||
"allTools": "All tools",
|
"allTools": "All tools",
|
||||||
"trigger": {
|
"trigger": {
|
||||||
|
"SessionStart": "Session Start",
|
||||||
"UserPromptSubmit": "User Prompt Submit",
|
"UserPromptSubmit": "User Prompt Submit",
|
||||||
"PreToolUse": "Pre Tool Use",
|
"PreToolUse": "Pre Tool Use",
|
||||||
"PostToolUse": "Post Tool Use",
|
"PostToolUse": "Post Tool Use",
|
||||||
@@ -66,29 +67,13 @@
|
|||||||
"automation": "Automation"
|
"automation": "Automation"
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"ccw-status-tracker": {
|
"session-start-notify": {
|
||||||
"name": "CCW Status Tracker",
|
"name": "Session Start Notify",
|
||||||
"description": "Parse CCW status.json and display current/next command"
|
"description": "Notify dashboard when a new workflow session is created"
|
||||||
},
|
},
|
||||||
"ccw-notify": {
|
"session-state-watch": {
|
||||||
"name": "CCW Dashboard Notify",
|
"name": "Session State Watch",
|
||||||
"description": "Send notifications to CCW dashboard when files are written"
|
"description": "Watch for session metadata file changes (workflow-session.json)"
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Commands Manager",
|
"title": "Commands Manager",
|
||||||
"description": "Manage custom slash commands for Claude Code",
|
"description": "Enable/disable CCW commands",
|
||||||
"actions": {
|
"actions": {
|
||||||
"create": "New Command",
|
"create": "New Command",
|
||||||
"edit": "Edit Command",
|
"edit": "Edit Command",
|
||||||
@@ -8,18 +8,24 @@
|
|||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"expandAll": "Expand All",
|
"expandAll": "Expand All",
|
||||||
"collapseAll": "Collapse All",
|
"collapseAll": "Collapse All",
|
||||||
"copy": "Copy"
|
"copy": "Copy",
|
||||||
|
"showDisabled": "Show Disabled",
|
||||||
|
"hideDisabled": "Hide Disabled"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"builtin": "Built-in",
|
"builtin": "Built-in",
|
||||||
"custom": "Custom"
|
"custom": "Custom"
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"project": "Project",
|
||||||
|
"user": "Global"
|
||||||
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"allCategories": "All Categories",
|
"allCategories": "All Categories",
|
||||||
"allSources": "All Sources",
|
"allSources": "All Sources",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"searchPlaceholder": "Search commands by name, description, or alias..."
|
"searchPlaceholder": "Search commands by name or description..."
|
||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -39,5 +45,17 @@
|
|||||||
"description": "Description",
|
"description": "Description",
|
||||||
"scope": "Scope",
|
"scope": "Scope",
|
||||||
"status": "Status"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,23 @@
|
|||||||
"title": "No {type} sessions",
|
"title": "No {type} sessions",
|
||||||
"message": "Create a new session to get started."
|
"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",
|
"flowchart": "Flowchart",
|
||||||
"implementationFlow": "Implementation Flow",
|
"implementationFlow": "Implementation Flow",
|
||||||
"focusPaths": "Focus Paths",
|
"focusPaths": "Focus Paths",
|
||||||
"acceptanceCriteria": "Acceptance Criteria",
|
"acceptanceCriteria": "Acceptance Criteria",
|
||||||
|
"dependsOn": "Depends On",
|
||||||
|
"tasksCount": "tasks",
|
||||||
"emptyDetail": {
|
"emptyDetail": {
|
||||||
"title": "No tasks in this session",
|
"title": "No tasks in this session",
|
||||||
"message": "This session does not contain any tasks yet."
|
"message": "This session does not contain any tasks yet."
|
||||||
@@ -24,5 +37,29 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Lite Task Not Found",
|
"title": "Lite Task Not Found",
|
||||||
"message": "The requested lite task session could not be 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,32 @@
|
|||||||
"skipped": "Skipped"
|
"skipped": "Skipped"
|
||||||
},
|
},
|
||||||
"untitled": "Untitled Task",
|
"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": {
|
"empty": {
|
||||||
"title": "No Tasks Found",
|
"title": "No Tasks Found",
|
||||||
"message": "This session has no tasks yet."
|
"message": "This session has no tasks yet."
|
||||||
@@ -107,6 +133,7 @@
|
|||||||
"default": "Summary",
|
"default": "Summary",
|
||||||
"title": "Session Summary",
|
"title": "Session Summary",
|
||||||
"lines": "lines",
|
"lines": "lines",
|
||||||
|
"viewFull": "View Full Summary ({count} lines)",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No Summary Available",
|
"title": "No Summary Available",
|
||||||
"message": "This session has no summary yet."
|
"message": "This session has no summary yet."
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"dimensions": "dimensions",
|
"dimensions": "dimensions",
|
||||||
"progress": "Progress",
|
"progress": "Progress",
|
||||||
"createdAt": "Created",
|
"createdAt": "Created",
|
||||||
"updatedAt": "Updated"
|
"updatedAt": "Updated",
|
||||||
|
"completed": "completed",
|
||||||
|
"updated": "Updated"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"title": "Skills",
|
"title": "Skills",
|
||||||
"description": "Manage and configure 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": {
|
"source": {
|
||||||
"builtin": "Built-in",
|
"builtin": "Built-in",
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
@@ -12,7 +23,9 @@
|
|||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"toggle": "Toggle",
|
"toggle": "Toggle",
|
||||||
"install": "Install Skill"
|
"install": "Install Skill",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirmDisable": "Disable"
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"recentPaths": "Recent Projects",
|
"recentPaths": "Recent Projects",
|
||||||
"noRecentPaths": "No recent projects",
|
"noRecentPaths": "No recent projects",
|
||||||
"current": "Current",
|
"current": "Current",
|
||||||
"browse": "Select Folder...",
|
"browse": "Browse Folder...",
|
||||||
|
"browseHint": "Select a folder from your computer",
|
||||||
|
"manualPath": "Enter Manually...",
|
||||||
"removePath": "Remove from recent",
|
"removePath": "Remove from recent",
|
||||||
"ariaLabel": "Workspace selector",
|
"ariaLabel": "Workspace selector",
|
||||||
"dialog": {
|
"dialog": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"description": "管理自动化工作流的 CLI 钩子",
|
"description": "管理自动化工作流的 CLI 钩子",
|
||||||
"allTools": "所有工具",
|
"allTools": "所有工具",
|
||||||
"trigger": {
|
"trigger": {
|
||||||
|
"SessionStart": "会话开始",
|
||||||
"UserPromptSubmit": "用户提交提示",
|
"UserPromptSubmit": "用户提交提示",
|
||||||
"PreToolUse": "工具使用前",
|
"PreToolUse": "工具使用前",
|
||||||
"PostToolUse": "工具使用后",
|
"PostToolUse": "工具使用后",
|
||||||
@@ -66,29 +67,13 @@
|
|||||||
"automation": "自动化"
|
"automation": "自动化"
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"ccw-status-tracker": {
|
"session-start-notify": {
|
||||||
"name": "CCW 状态追踪器",
|
"name": "会话启动通知",
|
||||||
"description": "解析 CCW status.json 并显示当前/下一个命令"
|
"description": "当新工作流会话创建时通知仪表盘"
|
||||||
},
|
},
|
||||||
"ccw-notify": {
|
"session-state-watch": {
|
||||||
"name": "CCW 面板通知",
|
"name": "会话状态监控",
|
||||||
"description": "当文件被写入时向 CCW 面板发送通知"
|
"description": "监控会话元数据文件变更 (workflow-session.json)"
|
||||||
},
|
|
||||||
"codexlens-update": {
|
|
||||||
"name": "CodexLens 自动更新",
|
|
||||||
"description": "当文件被写入或编辑时更新 CodexLens 索引"
|
|
||||||
},
|
|
||||||
"git-add": {
|
|
||||||
"name": "自动 Git 暂存",
|
|
||||||
"description": "自动将写入的文件暂存到 git"
|
|
||||||
},
|
|
||||||
"lint-check": {
|
|
||||||
"name": "自动 ESLint",
|
|
||||||
"description": "在写入后对 JavaScript/TypeScript 文件运行 ESLint"
|
|
||||||
},
|
|
||||||
"log-tool": {
|
|
||||||
"name": "工具使用日志",
|
|
||||||
"description": "将所有工具执行记录到文件以供审计"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "命令管理",
|
"title": "命令管理",
|
||||||
"description": "管理 Claude Code 自定义斜杠命令",
|
"description": "启用/禁用 CCW 命令",
|
||||||
"actions": {
|
"actions": {
|
||||||
"create": "新建命令",
|
"create": "新建命令",
|
||||||
"edit": "编辑命令",
|
"edit": "编辑命令",
|
||||||
@@ -8,18 +8,24 @@
|
|||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"expandAll": "全部展开",
|
"expandAll": "全部展开",
|
||||||
"collapseAll": "全部收起",
|
"collapseAll": "全部收起",
|
||||||
"copy": "复制"
|
"copy": "复制",
|
||||||
|
"showDisabled": "显示已禁用",
|
||||||
|
"hideDisabled": "隐藏已禁用"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"builtin": "内置",
|
"builtin": "内置",
|
||||||
"custom": "自定义"
|
"custom": "自定义"
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"project": "项目",
|
||||||
|
"user": "全局"
|
||||||
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"allCategories": "所有类别",
|
"allCategories": "所有类别",
|
||||||
"allSources": "所有来源",
|
"allSources": "所有来源",
|
||||||
"category": "类别",
|
"category": "类别",
|
||||||
"source": "来源",
|
"source": "来源",
|
||||||
"searchPlaceholder": "按名称、描述或别名搜索命令..."
|
"searchPlaceholder": "按名称或描述搜索命令..."
|
||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
@@ -39,5 +45,17 @@
|
|||||||
"description": "描述",
|
"description": "描述",
|
||||||
"scope": "作用域",
|
"scope": "作用域",
|
||||||
"status": "状态"
|
"status": "状态"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"total": "命令总数",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"enabled": "已启用",
|
||||||
|
"clickToEnableAll": "点击全部启用",
|
||||||
|
"clickToDisableAll": "点击全部禁用",
|
||||||
|
"noCommands": "此分组中没有命令",
|
||||||
|
"noEnabledCommands": "此分组中没有已启用的命令"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,23 @@
|
|||||||
"title": "没有 {type} 会话",
|
"title": "没有 {type} 会话",
|
||||||
"message": "创建新会话以开始使用。"
|
"message": "创建新会话以开始使用。"
|
||||||
},
|
},
|
||||||
|
"noResults": {
|
||||||
|
"title": "未找到结果",
|
||||||
|
"message": "请尝试调整搜索条件。"
|
||||||
|
},
|
||||||
|
"searchPlaceholder": "搜索会话或任务...",
|
||||||
|
"sortBy": "排序",
|
||||||
|
"sort": {
|
||||||
|
"date": "日期",
|
||||||
|
"name": "名称",
|
||||||
|
"tasks": "任务数"
|
||||||
|
},
|
||||||
"flowchart": "流程图",
|
"flowchart": "流程图",
|
||||||
"implementationFlow": "实现流程",
|
"implementationFlow": "实现流程",
|
||||||
"focusPaths": "关注路径",
|
"focusPaths": "关注路径",
|
||||||
"acceptanceCriteria": "验收标准",
|
"acceptanceCriteria": "验收标准",
|
||||||
|
"dependsOn": "依赖于",
|
||||||
|
"tasksCount": "个任务",
|
||||||
"emptyDetail": {
|
"emptyDetail": {
|
||||||
"title": "此会话中没有任务",
|
"title": "此会话中没有任务",
|
||||||
"message": "此会话尚不包含任何任务。"
|
"message": "此会话尚不包含任何任务。"
|
||||||
@@ -24,5 +37,29 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "未找到轻量任务",
|
"title": "未找到轻量任务",
|
||||||
"message": "无法找到请求的轻量任务会话。"
|
"message": "无法找到请求的轻量任务会话。"
|
||||||
|
},
|
||||||
|
"expandedTabs": {
|
||||||
|
"tasks": "任务",
|
||||||
|
"context": "上下文"
|
||||||
|
},
|
||||||
|
"contextPanel": {
|
||||||
|
"loading": "加载上下文中...",
|
||||||
|
"error": "加载上下文失败",
|
||||||
|
"empty": "此会话暂无上下文数据。",
|
||||||
|
"explorations": "探索结果",
|
||||||
|
"explorationsCount": "{count} 个角度",
|
||||||
|
"contextPackage": "上下文包",
|
||||||
|
"diagnoses": "诊断",
|
||||||
|
"diagnosesCount": "{count} 个条目",
|
||||||
|
"focusPaths": "关注路径",
|
||||||
|
"summary": "摘要",
|
||||||
|
"complexity": "复杂度",
|
||||||
|
"taskDescription": "任务描述"
|
||||||
|
},
|
||||||
|
"quickCards": {
|
||||||
|
"tasks": "任务",
|
||||||
|
"explorations": "探索",
|
||||||
|
"context": "上下文",
|
||||||
|
"diagnoses": "诊断"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,32 @@
|
|||||||
"skipped": "已跳过"
|
"skipped": "已跳过"
|
||||||
},
|
},
|
||||||
"untitled": "无标题任务",
|
"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": {
|
"empty": {
|
||||||
"title": "未找到任务",
|
"title": "未找到任务",
|
||||||
"message": "该会话暂无任务。"
|
"message": "该会话暂无任务。"
|
||||||
@@ -104,7 +130,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
|
"default": "摘要",
|
||||||
"title": "会话摘要",
|
"title": "会话摘要",
|
||||||
|
"lines": "行",
|
||||||
|
"viewFull": "查看完整摘要({count} 行)",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "暂无摘要",
|
"title": "暂无摘要",
|
||||||
"message": "该会话暂无摘要。"
|
"message": "该会话暂无摘要。"
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"dimensions": "维度",
|
"dimensions": "维度",
|
||||||
"progress": "进度",
|
"progress": "进度",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"updatedAt": "更新时间"
|
"updatedAt": "更新时间",
|
||||||
|
"completed": "已完成",
|
||||||
|
"updated": "更新于"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"overview": "概览",
|
"overview": "概览",
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"title": "技能",
|
"title": "技能",
|
||||||
"description": "管理和配置技能",
|
"description": "管理和配置技能",
|
||||||
|
"disabledSkills": {
|
||||||
|
"title": "已禁用技能"
|
||||||
|
},
|
||||||
|
"disableConfirm": {
|
||||||
|
"title": "禁用技能?",
|
||||||
|
"message": "确定要禁用 \"{name}\" 吗?"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"project": "项目",
|
||||||
|
"user": "全局"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"builtin": "内置",
|
"builtin": "内置",
|
||||||
"custom": "自定义",
|
"custom": "自定义",
|
||||||
@@ -12,7 +23,9 @@
|
|||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
"toggle": "切换",
|
"toggle": "切换",
|
||||||
"install": "安装技能"
|
"install": "安装技能",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirmDisable": "禁用"
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"recentPaths": "最近的项目",
|
"recentPaths": "最近的项目",
|
||||||
"noRecentPaths": "没有最近的项目",
|
"noRecentPaths": "没有最近的项目",
|
||||||
"current": "当前",
|
"current": "当前",
|
||||||
"browse": "选择文件夹...",
|
"browse": "浏览文件夹...",
|
||||||
|
"browseHint": "从计算机选择文件夹",
|
||||||
|
"manualPath": "手动输入...",
|
||||||
"removePath": "从最近记录中移除",
|
"removePath": "从最近记录中移除",
|
||||||
"ariaLabel": "工作空间选择器",
|
"ariaLabel": "工作空间选择器",
|
||||||
"dialog": {
|
"dialog": {
|
||||||
|
|||||||
@@ -10,268 +10,191 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Copy,
|
Eye,
|
||||||
ChevronDown,
|
EyeOff,
|
||||||
ChevronUp,
|
CheckCircle2,
|
||||||
Code,
|
XCircle,
|
||||||
BookOpen,
|
|
||||||
Tag,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { useCommands, useCommandMutations } from '@/hooks';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
import { CommandGroupAccordion } from '@/components/commands/CommandGroupAccordion';
|
||||||
import { useCommands } from '@/hooks';
|
import { LocationSwitcher } from '@/components/commands/LocationSwitcher';
|
||||||
import type { Command } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/utils';
|
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 (
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={onToggleExpand}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="p-2 rounded-lg bg-primary/10">
|
|
||||||
<Terminal className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-sm font-mono font-medium text-foreground">
|
|
||||||
/{command.name}
|
|
||||||
</code>
|
|
||||||
{command.source && (
|
|
||||||
<Badge variant={command.source === 'builtin' ? 'default' : 'secondary'} className="text-xs">
|
|
||||||
{command.source}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{command.description || formatMessage({ id: 'commands.card.noDescription' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCopy(`/${command.name}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category and Aliases */}
|
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
|
||||||
{command.category && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<Tag className="w-3 h-3 mr-1" />
|
|
||||||
{command.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{command.aliases?.map((alias) => (
|
|
||||||
<Badge key={alias} variant="secondary" className="text-xs font-mono">
|
|
||||||
/{alias}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded Content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
|
||||||
{/* Usage */}
|
|
||||||
{command.usage && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
|
||||||
<Code className="w-4 h-4" />
|
|
||||||
{formatMessage({ id: 'commands.card.usage' })}
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-background rounded-md font-mono text-sm overflow-x-auto">
|
|
||||||
<code>{command.usage}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Examples */}
|
|
||||||
{command.examples && command.examples.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
|
||||||
<BookOpen className="w-4 h-4" />
|
|
||||||
{formatMessage({ id: 'commands.card.examples' })}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{command.examples.map((example, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="p-3 bg-background rounded-md font-mono text-sm flex items-center justify-between gap-2 group"
|
|
||||||
>
|
|
||||||
<code className="overflow-x-auto flex-1">{example}</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => onCopy(example)}
|
|
||||||
>
|
|
||||||
<Copy className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Main Page Component ==========
|
// ========== Main Page Component ==========
|
||||||
|
|
||||||
export function CommandsManagerPage() {
|
export function CommandsManagerPage() {
|
||||||
const { formatMessage } = useIntl();
|
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<Set<string>>(new Set(['cli', 'workflow']));
|
||||||
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
|
||||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
|
||||||
const [expandedCommands, setExpandedCommands] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
commands,
|
commands,
|
||||||
categories,
|
groupedCommands,
|
||||||
totalCount,
|
groups,
|
||||||
|
enabledCount,
|
||||||
|
disabledCount,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
refetch,
|
refetch,
|
||||||
} = useCommands({
|
} = useCommands({
|
||||||
filter: {
|
filter: {
|
||||||
|
location: locationFilter,
|
||||||
|
showDisabled: showDisabledCommands,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
|
||||||
source: sourceFilter !== 'all' ? sourceFilter as Command['source'] : undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleExpand = (commandName: string) => {
|
const { toggleCommand, toggleGroup, isToggling } = useCommandMutations();
|
||||||
setExpandedCommands((prev) => {
|
|
||||||
|
// Toggle group expand/collapse
|
||||||
|
const toggleGroupExpand = (groupName: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(commandName)) {
|
if (next.has(groupName)) {
|
||||||
next.delete(commandName);
|
next.delete(groupName);
|
||||||
} else {
|
} else {
|
||||||
next.add(commandName);
|
next.add(groupName);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expand all groups
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setExpandedCommands(new Set(commands.map((c) => c.name)));
|
setExpandedGroups(new Set(groups));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Collapse all groups
|
||||||
const collapseAll = () => {
|
const collapseAll = () => {
|
||||||
setExpandedCommands(new Set());
|
setExpandedGroups(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
// Toggle individual command
|
||||||
|
const handleToggleCommand = async (name: string, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await toggleCommand(name, enabled, locationFilter);
|
||||||
// TODO: Show toast notification
|
} catch (error) {
|
||||||
} catch (err) {
|
console.error('Failed to toggle command:', error);
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Count by source
|
// Toggle all commands in a group
|
||||||
const builtinCount = useMemo(
|
const handleToggleGroup = async (groupName: string, enable: boolean) => {
|
||||||
() => commands.filter((c) => c.source === 'builtin').length,
|
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]
|
[commands]
|
||||||
);
|
);
|
||||||
const customCount = useMemo(
|
const userCount = useMemo(
|
||||||
() => commands.filter((c) => c.source === 'custom').length,
|
() => commands.filter((c) => c.location === 'user').length,
|
||||||
[commands]
|
[commands]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
<div>
|
||||||
<Terminal className="w-6 h-6 text-primary" />
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
{formatMessage({ id: 'commands.title' })}
|
<Terminal className="w-6 h-6 text-primary" />
|
||||||
</h1>
|
{formatMessage({ id: 'commands.title' })}
|
||||||
<p className="text-muted-foreground mt-1">
|
</h1>
|
||||||
{formatMessage({ id: 'commands.description' })}
|
<p className="text-muted-foreground mt-1">
|
||||||
</p>
|
{formatMessage({ id: 'commands.description' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
{formatMessage({ id: 'common.actions.refresh' })}
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{formatMessage({ id: 'commands.actions.create' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
{/* Location and Show Disabled Controls */}
|
||||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
{formatMessage({ id: 'common.actions.refresh' })}
|
<LocationSwitcher
|
||||||
</Button>
|
currentLocation={locationFilter}
|
||||||
<Button>
|
onLocationChange={setLocationFilter}
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
projectCount={projectCount}
|
||||||
{formatMessage({ id: 'commands.actions.create' })}
|
userCount={userCount}
|
||||||
</Button>
|
disabled={isToggling}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={showDisabledCommands ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDisabledCommands((prev) => !prev)}
|
||||||
|
disabled={isToggling}
|
||||||
|
>
|
||||||
|
{showDisabledCommands ? (
|
||||||
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{showDisabledCommands
|
||||||
|
? formatMessage({ id: 'commands.actions.hideDisabled' })
|
||||||
|
: formatMessage({ id: 'commands.actions.showDisabled' })}
|
||||||
|
<span className="ml-1 text-xs opacity-70">({disabledCount})</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Summary Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="w-5 h-5 text-primary" />
|
<Terminal className="w-5 h-5 text-primary" />
|
||||||
<span className="text-2xl font-bold">{totalCount}</span>
|
<span className="text-2xl font-bold">{commands.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.totalCommands' })}</p>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'commands.stats.total' })}
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Code className="w-5 h-5 text-info" />
|
<CheckCircle2 className="w-5 h-5 text-success" />
|
||||||
<span className="text-2xl font-bold">{builtinCount}</span>
|
<span className="text-2xl font-bold">{enabledCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'commands.source.builtin' })}</p>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'commands.stats.enabled' })}
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="w-5 h-5 text-success" />
|
<XCircle className="w-5 h-5 text-muted-foreground" />
|
||||||
<span className="text-2xl font-bold">{customCount}</span>
|
<span className="text-2xl font-bold">{disabledCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'commands.source.custom' })}</p>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
</Card>
|
{formatMessage({ id: 'commands.stats.disabled' })}
|
||||||
<Card className="p-4">
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Tag className="w-5 h-5 text-warning" />
|
|
||||||
<span className="text-2xl font-bold">{categories.length}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.categories' })}</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Search and Expand/Collapse Controls */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
@@ -282,67 +205,51 @@ export function CommandsManagerPage() {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
<Button variant="outline" size="sm" onClick={expandAll} disabled={groups.length === 0}>
|
||||||
<SelectTrigger className="w-[140px]">
|
{formatMessage({ id: 'commands.actions.expandAll' })}
|
||||||
<SelectValue placeholder={formatMessage({ id: 'commands.filters.category' })} />
|
</Button>
|
||||||
</SelectTrigger>
|
<Button variant="outline" size="sm" onClick={collapseAll} disabled={groups.length === 0}>
|
||||||
<SelectContent>
|
{formatMessage({ id: 'commands.actions.collapseAll' })}
|
||||||
<SelectItem value="all">{formatMessage({ id: 'commands.filters.allCategories' })}</SelectItem>
|
</Button>
|
||||||
{categories.map((cat) => (
|
|
||||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder={formatMessage({ id: 'commands.filters.source' })} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">{formatMessage({ id: 'commands.filters.allSources' })}</SelectItem>
|
|
||||||
<SelectItem value="builtin">{formatMessage({ id: 'commands.source.builtin' })}</SelectItem>
|
|
||||||
<SelectItem value="custom">{formatMessage({ id: 'commands.source.custom' })}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expand/Collapse All */}
|
{/* Command Groups Accordion */}
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="sm" onClick={expandAll}>
|
|
||||||
{formatMessage({ id: 'commands.actions.expandAll' })}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={collapseAll}>
|
|
||||||
{formatMessage({ id: 'commands.actions.collapseAll' })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Commands List */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
|
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : commands.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'commands.emptyState.title' })}</h3>
|
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'commands.emptyState.title' })}
|
||||||
|
</h3>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
{formatMessage({ id: 'commands.emptyState.message' })}
|
{formatMessage({ id: 'commands.emptyState.message' })}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="commands-accordion">
|
||||||
{commands.map((command) => (
|
{groups.map((groupName) => {
|
||||||
<CommandCard
|
const groupCommands = groupedCommands[groupName] || [];
|
||||||
key={command.name}
|
return (
|
||||||
command={command}
|
<CommandGroupAccordion
|
||||||
isExpanded={expandedCommands.has(command.name)}
|
key={groupName}
|
||||||
onToggleExpand={() => toggleExpand(command.name)}
|
groupName={groupName}
|
||||||
onCopy={copyToClipboard}
|
commands={groupCommands}
|
||||||
/>
|
isExpanded={expandedGroups.has(groupName)}
|
||||||
))}
|
onToggleExpand={toggleGroupExpand}
|
||||||
|
onToggleCommand={handleToggleCommand}
|
||||||
|
onToggleGroup={handleToggleGroup}
|
||||||
|
isToggling={isToggling}
|
||||||
|
showDisabled={showDisabledCommands}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export function FixSessionPage() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">
|
||||||
{task.task_id || task.id || 'N/A'}
|
{task.task_id || task.id || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={statusBadge.variant} className="gap-1">
|
<Badge variant={statusBadge.variant} className="gap-1">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Play,
|
||||||
Zap,
|
Zap,
|
||||||
Wrench,
|
Wrench,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@@ -32,6 +33,7 @@ import { cn } from '@/lib/utils';
|
|||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
interface HooksByTrigger {
|
interface HooksByTrigger {
|
||||||
|
SessionStart: HookCardData[];
|
||||||
UserPromptSubmit: HookCardData[];
|
UserPromptSubmit: HookCardData[];
|
||||||
PreToolUse: HookCardData[];
|
PreToolUse: HookCardData[];
|
||||||
PostToolUse: HookCardData[];
|
PostToolUse: HookCardData[];
|
||||||
@@ -41,7 +43,7 @@ interface HooksByTrigger {
|
|||||||
// ========== Helper Functions ==========
|
// ========== Helper Functions ==========
|
||||||
|
|
||||||
function isHookTriggerType(value: string): value is HookTriggerType {
|
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 {
|
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 {
|
function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger {
|
||||||
return {
|
return {
|
||||||
|
SessionStart: hooks.filter((h) => h.trigger === 'SessionStart'),
|
||||||
UserPromptSubmit: hooks.filter((h) => h.trigger === 'UserPromptSubmit'),
|
UserPromptSubmit: hooks.filter((h) => h.trigger === 'UserPromptSubmit'),
|
||||||
PreToolUse: hooks.filter((h) => h.trigger === 'PreToolUse'),
|
PreToolUse: hooks.filter((h) => h.trigger === 'PreToolUse'),
|
||||||
PostToolUse: hooks.filter((h) => h.trigger === 'PostToolUse'),
|
PostToolUse: hooks.filter((h) => h.trigger === 'PostToolUse'),
|
||||||
@@ -69,6 +72,10 @@ function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger {
|
|||||||
|
|
||||||
function getTriggerStats(hooksByTrigger: HooksByTrigger) {
|
function getTriggerStats(hooksByTrigger: HooksByTrigger) {
|
||||||
return {
|
return {
|
||||||
|
SessionStart: {
|
||||||
|
total: hooksByTrigger.SessionStart.length,
|
||||||
|
enabled: hooksByTrigger.SessionStart.filter((h) => h.enabled).length,
|
||||||
|
},
|
||||||
UserPromptSubmit: {
|
UserPromptSubmit: {
|
||||||
total: hooksByTrigger.UserPromptSubmit.length,
|
total: hooksByTrigger.UserPromptSubmit.length,
|
||||||
enabled: hooksByTrigger.UserPromptSubmit.filter((h) => h.enabled).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 }> = [
|
const TRIGGER_TYPES: Array<{ type: HookTriggerType; icon: typeof Zap }> = [
|
||||||
|
{ type: 'SessionStart', icon: Play },
|
||||||
{ type: 'UserPromptSubmit', icon: Zap },
|
{ type: 'UserPromptSubmit', icon: Zap },
|
||||||
{ type: 'PreToolUse', icon: Wrench },
|
{ type: 'PreToolUse', icon: Wrench },
|
||||||
{ type: 'PostToolUse', icon: CheckCircle },
|
{ type: 'PostToolUse', icon: CheckCircle },
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ export function LiteTaskDetailPage() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CardTitle className="text-base font-medium flex items-center gap-2 flex-wrap">
|
<CardTitle className="text-base font-medium flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">{taskId}</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : 'secondary'}
|
variant={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : 'secondary'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,13 +12,24 @@ import {
|
|||||||
FileEdit,
|
FileEdit,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Calendar,
|
Calendar,
|
||||||
ListChecks,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
Activity,
|
Activity,
|
||||||
Repeat,
|
Repeat,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
SortAsc,
|
||||||
|
SortDesc,
|
||||||
|
ListFilter,
|
||||||
|
Hash,
|
||||||
|
ListChecks,
|
||||||
|
Package,
|
||||||
|
Loader2,
|
||||||
|
Compass,
|
||||||
|
Stethoscope,
|
||||||
|
FolderOpen,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useLiteTasks } from '@/hooks/useLiteTasks';
|
import { useLiteTasks } from '@/hooks/useLiteTasks';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -26,10 +37,12 @@ import { Badge } from '@/components/ui/Badge';
|
|||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||||
import { TaskDrawer } from '@/components/shared/TaskDrawer';
|
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';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
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)
|
* 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;
|
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<ExpandedTab>('tasks');
|
||||||
|
const [contextData, setContextData] = React.useState<LiteSessionContext | null>(null);
|
||||||
|
const [contextLoading, setContextLoading] = React.useState(false);
|
||||||
|
const [contextError, setContextError] = React.useState<string | null>(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 (
|
||||||
|
<div className="mt-2 ml-6 pb-2">
|
||||||
|
{/* Quick Info Cards */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setActiveTab('tasks'); }}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||||
|
activeTab === 'tasks'
|
||||||
|
? 'bg-primary/10 text-primary border-primary/30'
|
||||||
|
: 'bg-muted/50 text-muted-foreground border-border hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
{formatMessage({ id: 'liteTasks.quickCards.tasks' })}
|
||||||
|
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">
|
||||||
|
{taskCount}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setActiveTab('context'); }}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||||
|
activeTab === 'context'
|
||||||
|
? 'bg-primary/10 text-primary border-primary/30'
|
||||||
|
: 'bg-muted/50 text-muted-foreground border-border hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Package className="h-3.5 w-3.5" />
|
||||||
|
{formatMessage({ id: 'liteTasks.quickCards.context' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks Tab */}
|
||||||
|
{activeTab === 'tasks' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((task, index) => (
|
||||||
|
<Card
|
||||||
|
key={task.id || index}
|
||||||
|
className="cursor-pointer hover:shadow-sm hover:border-primary/50 transition-all border-border"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskClick(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className="text-xs font-mono shrink-0 bg-primary/10 text-primary border-primary/20">
|
||||||
|
{task.task_id || `#${index + 1}`}
|
||||||
|
</Badge>
|
||||||
|
<h4 className="text-sm font-medium text-foreground flex-1 line-clamp-1">
|
||||||
|
{task.title || formatMessage({ id: 'liteTasks.untitled' })}
|
||||||
|
</h4>
|
||||||
|
{task.status && (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
task.status === 'completed' ? 'success' :
|
||||||
|
task.status === 'in_progress' ? 'warning' :
|
||||||
|
task.status === 'blocked' ? 'destructive' : 'secondary'
|
||||||
|
}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{task.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5 pl-[calc(1.5rem+0.75rem)] line-clamp-2">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Context Tab */}
|
||||||
|
{activeTab === 'context' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{contextLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
<span className="text-sm">{formatMessage({ id: 'liteTasks.contextPanel.loading' })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contextError && !contextLoading && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive text-sm">
|
||||||
|
<XCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.error' })}: {contextError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!contextLoading && !contextError && contextData && (
|
||||||
|
<ContextContent contextData={contextData} session={session} />
|
||||||
|
)}
|
||||||
|
{!contextLoading && !contextError && !contextData && !session.path && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Package className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.empty' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Package className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.empty' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Explorations Section */}
|
||||||
|
{hasExplorations && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<Compass className="h-4 w-4" />}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!!contextData.explorations?.manifest?.task_description && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.taskDescription' })}:
|
||||||
|
</span>{' '}
|
||||||
|
{String(contextData.explorations.manifest.task_description)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!contextData.explorations?.manifest?.complexity && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.complexity' })}:
|
||||||
|
</span>{' '}
|
||||||
|
<Badge variant="info" className="text-[10px]">
|
||||||
|
{String(contextData.explorations.manifest.complexity)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contextData.explorations?.data && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{Object.keys(contextData.explorations.data).map((angle) => (
|
||||||
|
<Badge key={angle} variant="secondary" className="text-[10px] capitalize">
|
||||||
|
{angle.replace(/-/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Diagnoses Section */}
|
||||||
|
{hasDiagnoses && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<Stethoscope className="h-4 w-4" />}
|
||||||
|
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) => (
|
||||||
|
<div key={i} className="text-xs text-muted-foreground py-1 border-b border-border/50 last:border-0">
|
||||||
|
{(item.title as string) || (item.description as string) || `Diagnosis ${i + 1}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Context Package Section */}
|
||||||
|
{hasContext && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<Package className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.contextPackage' })}
|
||||||
|
>
|
||||||
|
<pre className="text-xs text-muted-foreground overflow-auto max-h-48 bg-muted/50 rounded p-2 whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(contextData.context, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Focus Paths from Plan */}
|
||||||
|
{hasFocusPaths && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<FolderOpen className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.focusPaths' })}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(plan.focus_paths as string[]).map((p, i) => (
|
||||||
|
<Badge key={i} variant="secondary" className="text-[10px] font-mono">
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plan Summary */}
|
||||||
|
{hasSummary && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.summary' })}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">{plan.summary as string}</p>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<Card className="border-border" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
<span className="text-sm font-medium text-foreground flex-1">{title}</span>
|
||||||
|
{badge && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">{badge}</Badge>
|
||||||
|
)}
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<CardContent className="px-3 pb-3 pt-0">
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LiteTasksPage component - Display lite-plan and lite-fix sessions with expandable tasks
|
* LiteTasksPage component - Display lite-plan and lite-fix sessions with expandable tasks
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +398,62 @@ export function LiteTasksPage() {
|
|||||||
const [activeTab, setActiveTab] = React.useState<LiteTaskTab>('lite-plan');
|
const [activeTab, setActiveTab] = React.useState<LiteTaskTab>('lite-plan');
|
||||||
const [expandedSessionId, setExpandedSessionId] = React.useState<string | null>(null);
|
const [expandedSessionId, setExpandedSessionId] = React.useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = React.useState<LiteTask | null>(null);
|
const [selectedTask, setSelectedTask] = React.useState<LiteTask | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
const [sortField, setSortField] = React.useState<SortField>('date');
|
||||||
|
const [sortOrder, setSortOrder] = React.useState<SortOrder>('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 = () => {
|
const handleBack = () => {
|
||||||
navigate('/sessions');
|
navigate('/sessions');
|
||||||
@@ -96,7 +499,7 @@ export function LiteTasksPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
|
<h3 className="font-bold text-foreground text-sm tracking-wide uppercase">{session.id}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={isLitePlan ? 'secondary' : 'warning'} className="gap-1 flex-shrink-0">
|
<Badge variant={isLitePlan ? 'secondary' : 'warning'} className="gap-1 flex-shrink-0">
|
||||||
@@ -104,64 +507,29 @@ export function LiteTasksPage() {
|
|||||||
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
|
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
{session.createdAt && (
|
{session.createdAt && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
{new Date(session.createdAt).toLocaleDateString()}
|
{new Date(session.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1">
|
{taskCount > 0 && (
|
||||||
<ListChecks className="h-3.5 w-3.5" />
|
<span className="flex items-center gap-1">
|
||||||
{taskCount} {formatMessage({ id: 'session.tasks' })}
|
<Hash className="h-3.5 w-3.5" />
|
||||||
</span>
|
{taskCount} {formatMessage({ id: 'liteTasks.tasksCount' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Expanded tasks list */}
|
{/* Expanded tasks panel with tabs */}
|
||||||
{isExpanded && session.tasks && session.tasks.length > 0 && (
|
{isExpanded && session.tasks && session.tasks.length > 0 && (
|
||||||
<div className="mt-2 ml-6 space-y-2 pb-2">
|
<ExpandedSessionPanel
|
||||||
{session.tasks.map((task, index) => {
|
session={session}
|
||||||
const taskStatusColor = task.status === 'completed' ? 'success' :
|
onTaskClick={setSelectedTask}
|
||||||
task.status === 'in_progress' ? 'warning' :
|
/>
|
||||||
task.status === 'failed' ? 'destructive' : 'secondary';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={task.id || index}
|
|
||||||
className="cursor-pointer hover:shadow-sm hover:border-primary/50 transition-all border-border"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedTask(task);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
|
||||||
{task.task_id || `#${index + 1}`}
|
|
||||||
</span>
|
|
||||||
<Badge variant={taskStatusColor as 'success' | 'warning' | 'destructive' | 'secondary'} className="text-xs">
|
|
||||||
{task.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground">
|
|
||||||
{task.title || formatMessage({ id: 'liteTasks.untitled' })}
|
|
||||||
</h4>
|
|
||||||
{task.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{task.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -195,7 +563,7 @@ export function LiteTasksPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
|
<h3 className="font-bold text-foreground text-sm tracking-wide uppercase">{session.id}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="gap-1 flex-shrink-0">
|
<Badge variant="secondary" className="gap-1 flex-shrink-0">
|
||||||
@@ -308,6 +676,58 @@ export function LiteTasksPage() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Search and Sort Toolbar */}
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={formatMessage({ id: 'liteTasks.searchPlaceholder' })}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<ListFilter className="h-3.5 w-3.5" />
|
||||||
|
{formatMessage({ id: 'liteTasks.sortBy' })}:
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant={sortField === 'date' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleSort('date')}
|
||||||
|
className="h-8 px-3 text-xs gap-1"
|
||||||
|
>
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{formatMessage({ id: 'liteTasks.sort.date' })}
|
||||||
|
{sortField === 'date' && (sortOrder === 'desc' ? <SortDesc className="h-3 w-3" /> : <SortAsc className="h-3 w-3" />)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortField === 'name' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleSort('name')}
|
||||||
|
className="h-8 px-3 text-xs gap-1"
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'liteTasks.sort.name' })}
|
||||||
|
{sortField === 'name' && (sortOrder === 'desc' ? <SortDesc className="h-3 w-3" /> : <SortAsc className="h-3 w-3" />)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortField === 'tasks' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleSort('tasks')}
|
||||||
|
className="h-8 px-3 text-xs gap-1"
|
||||||
|
>
|
||||||
|
<Hash className="h-3.5 w-3.5" />
|
||||||
|
{formatMessage({ id: 'liteTasks.sort.tasks' })}
|
||||||
|
{sortField === 'tasks' && (sortOrder === 'desc' ? <SortDesc className="h-3 w-3" /> : <SortAsc className="h-3 w-3" />)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Lite Plan Tab */}
|
{/* Lite Plan Tab */}
|
||||||
<TabsContent value="lite-plan" className="mt-4">
|
<TabsContent value="lite-plan" className="mt-4">
|
||||||
{litePlan.length === 0 ? (
|
{litePlan.length === 0 ? (
|
||||||
@@ -320,8 +740,18 @@ export function LiteTasksPage() {
|
|||||||
{formatMessage({ id: 'liteTasks.empty.message' })}
|
{formatMessage({ id: 'liteTasks.empty.message' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredLitePlan.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
{formatMessage({ id: 'liteTasks.noResults.title' })}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.noResults.message' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">{litePlan.map(renderLiteTaskCard)}</div>
|
<div className="grid gap-3">{filteredLitePlan.map(renderLiteTaskCard)}</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -337,8 +767,18 @@ export function LiteTasksPage() {
|
|||||||
{formatMessage({ id: 'liteTasks.empty.message' })}
|
{formatMessage({ id: 'liteTasks.empty.message' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredLiteFix.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
{formatMessage({ id: 'liteTasks.noResults.title' })}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.noResults.message' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">{liteFix.map(renderLiteTaskCard)}</div>
|
<div className="grid gap-3">{filteredLiteFix.map(renderLiteTaskCard)}</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -354,8 +794,18 @@ export function LiteTasksPage() {
|
|||||||
{formatMessage({ id: 'liteTasks.empty.message' })}
|
{formatMessage({ id: 'liteTasks.empty.message' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredMultiCliPlan.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
{formatMessage({ id: 'liteTasks.noResults.title' })}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.noResults.message' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">{multiCliPlan.map(renderMultiCliCard)}</div>
|
<div className="grid gap-3">{filteredMultiCliPlan.map(renderMultiCliCard)}</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import * as React from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useSessions,
|
useSessions,
|
||||||
useCreateSession,
|
|
||||||
useArchiveSession,
|
useArchiveSession,
|
||||||
useDeleteSession,
|
useDeleteSession,
|
||||||
type SessionsFilter,
|
type SessionsFilter,
|
||||||
@@ -61,15 +59,9 @@ export function SessionsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = React.useState<SessionMetadata['status'][]>([]);
|
const [statusFilter, setStatusFilter] = React.useState<SessionMetadata['status'][]>([]);
|
||||||
|
|
||||||
// Dialog state
|
// Dialog state
|
||||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||||
const [sessionToDelete, setSessionToDelete] = React.useState<string | null>(null);
|
const [sessionToDelete, setSessionToDelete] = React.useState<string | null>(null);
|
||||||
|
|
||||||
// Create session form state
|
|
||||||
const [newSessionId, setNewSessionId] = React.useState('');
|
|
||||||
const [newSessionTitle, setNewSessionTitle] = React.useState('');
|
|
||||||
const [newSessionDescription, setNewSessionDescription] = React.useState('');
|
|
||||||
|
|
||||||
// Build filter object
|
// Build filter object
|
||||||
const filter: SessionsFilter = React.useMemo(
|
const filter: SessionsFilter = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -90,39 +82,16 @@ export function SessionsPage() {
|
|||||||
} = useSessions({ filter });
|
} = useSessions({ filter });
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const { createSession, isCreating } = useCreateSession();
|
|
||||||
const { archiveSession, isArchiving } = useArchiveSession();
|
const { archiveSession, isArchiving } = useArchiveSession();
|
||||||
const { deleteSession, isDeleting } = useDeleteSession();
|
const { deleteSession, isDeleting } = useDeleteSession();
|
||||||
|
|
||||||
const isMutating = isCreating || isArchiving || isDeleting;
|
const isMutating = isArchiving || isDeleting;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleSessionClick = (sessionId: string) => {
|
const handleSessionClick = (sessionId: string) => {
|
||||||
navigate(`/sessions/${sessionId}`);
|
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) => {
|
const handleArchive = async (sessionId: string) => {
|
||||||
try {
|
try {
|
||||||
await archiveSession(sessionId);
|
await archiveSession(sessionId);
|
||||||
@@ -185,10 +154,6 @@ export function SessionsPage() {
|
|||||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
{formatMessage({ id: 'common.actions.refresh' })}
|
{formatMessage({ id: 'common.actions.refresh' })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{formatMessage({ id: 'common.actions.new' })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,15 +290,10 @@ export function SessionsPage() {
|
|||||||
? formatMessage({ id: 'sessions.emptyState.message' })
|
? formatMessage({ id: 'sessions.emptyState.message' })
|
||||||
: formatMessage({ id: 'sessions.emptyState.createFirst' })}
|
: formatMessage({ id: 'sessions.emptyState.createFirst' })}
|
||||||
</p>
|
</p>
|
||||||
{hasActiveFilters ? (
|
{hasActiveFilters && (
|
||||||
<Button variant="outline" onClick={clearFilters}>
|
<Button variant="outline" onClick={clearFilters}>
|
||||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{formatMessage({ id: 'common.actions.new' })}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -352,70 +312,6 @@ export function SessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Session Dialog */}
|
|
||||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{formatMessage({ id: 'common.dialog.createSession' })}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{formatMessage({ id: 'common.dialog.createSessionDesc' })}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="sessionId" className="text-sm font-medium">
|
|
||||||
{formatMessage({ id: 'common.form.sessionId' })} <span className="text-destructive">*</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="sessionId"
|
|
||||||
placeholder={formatMessage({ id: 'common.form.sessionIdPlaceholder' })}
|
|
||||||
value={newSessionId}
|
|
||||||
onChange={(e) => setNewSessionId(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="sessionTitle" className="text-sm font-medium">
|
|
||||||
{formatMessage({ id: 'common.form.title' })} ({formatMessage({ id: 'common.form.optional' })})
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="sessionTitle"
|
|
||||||
placeholder={formatMessage({ id: 'common.form.titlePlaceholder' })}
|
|
||||||
value={newSessionTitle}
|
|
||||||
onChange={(e) => setNewSessionTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="sessionDescription" className="text-sm font-medium">
|
|
||||||
{formatMessage({ id: 'common.form.description' })} ({formatMessage({ id: 'common.form.optional' })})
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="sessionDescription"
|
|
||||||
placeholder={formatMessage({ id: 'common.form.descriptionPlaceholder' })}
|
|
||||||
value={newSessionDescription}
|
|
||||||
onChange={(e) => setNewSessionDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setCreateDialogOpen(false);
|
|
||||||
resetCreateForm();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatMessage({ id: 'common.actions.cancel' })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateSession}
|
|
||||||
disabled={!newSessionId.trim() || isCreating}
|
|
||||||
>
|
|
||||||
{isCreating ? formatMessage({ id: 'common.status.creating' }) : formatMessage({ id: 'common.actions.create' })}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -13,12 +13,28 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
Tag,
|
Tag,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
EyeOff,
|
||||||
|
List,
|
||||||
|
Grid3x3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
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 { SkillCard } from '@/components/shared/SkillCard';
|
||||||
|
import { LocationSwitcher } from '@/components/commands/LocationSwitcher';
|
||||||
import { useSkills, useSkillMutations } from '@/hooks';
|
import { useSkills, useSkillMutations } from '@/hooks';
|
||||||
import type { Skill } from '@/lib/api';
|
import type { Skill } from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -90,12 +106,17 @@ export function SkillsManagerPage() {
|
|||||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||||
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'compact'>('grid');
|
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 {
|
const {
|
||||||
skills,
|
skills,
|
||||||
categories,
|
categories,
|
||||||
totalCount,
|
totalCount,
|
||||||
enabledCount,
|
enabledCount,
|
||||||
|
projectSkills,
|
||||||
|
userSkills,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
refetch,
|
refetch,
|
||||||
@@ -105,11 +126,15 @@ export function SkillsManagerPage() {
|
|||||||
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||||
source: sourceFilter !== 'all' ? sourceFilter as Skill['source'] : undefined,
|
source: sourceFilter !== 'all' ? sourceFilter as Skill['source'] : undefined,
|
||||||
enabledOnly: enabledFilter === 'enabled',
|
enabledOnly: enabledFilter === 'enabled',
|
||||||
|
location: locationFilter,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { toggleSkill, isToggling } = useSkillMutations();
|
const { toggleSkill, isToggling } = useSkillMutations();
|
||||||
|
|
||||||
|
// Calculate disabled count
|
||||||
|
const disabledCount = totalCount - enabledCount;
|
||||||
|
|
||||||
// Filter skills based on enabled filter
|
// Filter skills based on enabled filter
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
if (enabledFilter === 'disabled') {
|
if (enabledFilter === 'disabled') {
|
||||||
@@ -119,32 +144,63 @@ export function SkillsManagerPage() {
|
|||||||
}, [skills, enabledFilter]);
|
}, [skills, enabledFilter]);
|
||||||
|
|
||||||
const handleToggle = async (skill: Skill, enabled: boolean) => {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
<div>
|
||||||
<Sparkles className="w-6 h-6 text-primary" />
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
{formatMessage({ id: 'skills.title' })}
|
<Sparkles className="w-6 h-6 text-primary" />
|
||||||
</h1>
|
{formatMessage({ id: 'skills.title' })}
|
||||||
<p className="text-muted-foreground mt-1">
|
</h1>
|
||||||
{formatMessage({ id: 'skills.description' })}
|
<p className="text-muted-foreground mt-1">
|
||||||
</p>
|
{formatMessage({ id: 'skills.description' })}
|
||||||
</div>
|
</p>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
<div className="flex gap-2">
|
||||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
{formatMessage({ id: 'common.actions.refresh' })}
|
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
</Button>
|
{formatMessage({ id: 'common.actions.refresh' })}
|
||||||
<Button>
|
</Button>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Button>
|
||||||
{formatMessage({ id: 'skills.actions.install' })}
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
{formatMessage({ id: 'skills.actions.install' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Location Switcher */}
|
||||||
|
<LocationSwitcher
|
||||||
|
currentLocation={locationFilter}
|
||||||
|
onLocationChange={setLocationFilter}
|
||||||
|
projectCount={projectSkills.length}
|
||||||
|
userCount={userSkills.length}
|
||||||
|
disabled={isToggling}
|
||||||
|
translationPrefix="skills"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
@@ -229,34 +285,39 @@ export function SkillsManagerPage() {
|
|||||||
{/* Quick Filters */}
|
{/* Quick Filters */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={enabledFilter === 'all' ? 'default' : 'outline'}
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEnabledFilter('all')}
|
onClick={() => setEnabledFilter('all')}
|
||||||
|
className={enabledFilter === 'all' ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-1" />
|
||||||
{formatMessage({ id: 'skills.filters.all' })} ({totalCount})
|
{formatMessage({ id: 'skills.filters.all' })} ({totalCount})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={enabledFilter === 'enabled' ? 'default' : 'outline'}
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEnabledFilter('enabled')}
|
onClick={() => setEnabledFilter('enabled')}
|
||||||
|
className={enabledFilter === 'enabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Power className="w-4 h-4 mr-1" />
|
<Power className="w-4 h-4 mr-1" />
|
||||||
{formatMessage({ id: 'skills.state.enabled' })} ({enabledCount})
|
{formatMessage({ id: 'skills.state.enabled' })} ({enabledCount})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={enabledFilter === 'disabled' ? 'default' : 'outline'}
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEnabledFilter('disabled')}
|
onClick={() => setEnabledFilter('disabled')}
|
||||||
|
className={enabledFilter === 'disabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<PowerOff className="w-4 h-4 mr-1" />
|
<PowerOff className="w-4 h-4 mr-1" />
|
||||||
{formatMessage({ id: 'skills.state.disabled' })} ({totalCount - enabledCount})
|
{formatMessage({ id: 'skills.state.disabled' })} ({disabledCount})
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
||||||
>
|
>
|
||||||
|
{viewMode === 'grid' ? <List className="w-4 h-4 mr-1" /> : <Grid3x3 className="w-4 h-4 mr-1" />}
|
||||||
{formatMessage({ id: viewMode === 'grid' ? 'skills.view.compact' : 'skills.view.grid' })}
|
{formatMessage({ id: viewMode === 'grid' ? 'skills.view.compact' : 'skills.view.grid' })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,11 +326,58 @@ export function SkillsManagerPage() {
|
|||||||
<SkillGrid
|
<SkillGrid
|
||||||
skills={filteredSkills}
|
skills={filteredSkills}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggleWithConfirm}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
isToggling={isToggling}
|
isToggling={isToggling}
|
||||||
compact={viewMode === 'compact'}
|
compact={viewMode === 'compact'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Disabled Skills Section */}
|
||||||
|
{enabledFilter === 'all' && disabledCount > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowDisabledSection(!showDisabledSection)}
|
||||||
|
className="mb-4 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showDisabledSection ? <ChevronDown className="w-4 h-4 mr-2" /> : <ChevronRight className="w-4 h-4 mr-2" />}
|
||||||
|
<EyeOff className="w-4 h-4 mr-2" />
|
||||||
|
{formatMessage({ id: 'skills.disabledSkills.title' })} ({disabledCount})
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showDisabledSection && (
|
||||||
|
<SkillGrid
|
||||||
|
skills={skills.filter((s) => !s.enabled)}
|
||||||
|
isLoading={false}
|
||||||
|
onToggle={handleToggleWithConfirm}
|
||||||
|
onClick={() => {}}
|
||||||
|
isToggling={isToggling}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disable Confirmation Dialog */}
|
||||||
|
<AlertDialog open={!!confirmDisable} onOpenChange={(open) => !open && setConfirmDisable(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{formatMessage({ id: 'skills.disableConfirm.title' })}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{formatMessage(
|
||||||
|
{ id: 'skills.disableConfirm.message' },
|
||||||
|
{ name: confirmDisable?.skill.name || '' }
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{formatMessage({ id: 'skills.actions.cancel' })}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmDisable} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
{formatMessage({ id: 'skills.actions.confirmDisable' })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,29 +71,14 @@ export function SummaryTab({ summary, summaries }: SummaryTabProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{summaryList.length === 1 ? (
|
{/* Always use truncated card display with modal viewer */}
|
||||||
// Single summary - inline display
|
{summaryList.map((item, index) => (
|
||||||
<Card>
|
<SummaryCard
|
||||||
<CardContent className="p-6">
|
key={index}
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
summary={item}
|
||||||
<FileText className="w-5 h-5" />
|
onClick={() => setSelectedSummary(item)}
|
||||||
{summaryList[0].name}
|
/>
|
||||||
</h3>
|
))}
|
||||||
<div className="prose prose-sm max-w-none text-foreground">
|
|
||||||
<p className="whitespace-pre-wrap">{summaryList[0].content}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
// Multiple summaries - card list with modal viewer
|
|
||||||
summaryList.map((item, index) => (
|
|
||||||
<SummaryCard
|
|
||||||
key={index}
|
|
||||||
summary={item}
|
|
||||||
onClick={() => setSelectedSummary(item)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Viewer */}
|
{/* Modal Viewer */}
|
||||||
@@ -119,24 +104,21 @@ interface SummaryCardProps {
|
|||||||
|
|
||||||
function SummaryCard({ summary, onClick }: SummaryCardProps) {
|
function SummaryCard({ summary, onClick }: SummaryCardProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
// Get preview (first 3 lines)
|
// Get preview (first 5 lines, matching ImplPlanTab style)
|
||||||
const lines = summary.content.split('\n');
|
const lines = summary.content.split('\n');
|
||||||
const preview = lines.slice(0, 3).join('\n');
|
const preview = lines.slice(0, 5).join('\n');
|
||||||
const hasMore = lines.length > 3;
|
const hasMore = lines.length > 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card>
|
||||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5" />
|
||||||
{summary.name}
|
{summary.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="outline" size="sm" onClick={onClick}>
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
{formatMessage({ id: 'common.actions.view' })}
|
{formatMessage({ id: 'common.actions.view' })}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -147,10 +129,15 @@ function SummaryCard({ summary, onClick }: SummaryCardProps) {
|
|||||||
{preview}{hasMore && '\n...'}
|
{preview}{hasMore && '\n...'}
|
||||||
</pre>
|
</pre>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="mt-4">
|
||||||
<Badge variant="secondary">
|
<Button
|
||||||
{lines.length} {formatMessage({ id: 'sessionDetail.summary.lines' })}
|
variant="outline"
|
||||||
</Badge>
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'sessionDetail.summary.viewFull' }, { count: lines.length })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -8,16 +8,31 @@ import { useIntl } from 'react-intl';
|
|||||||
import {
|
import {
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Code,
|
Code,
|
||||||
|
GitBranch,
|
||||||
|
Zap,
|
||||||
|
Calendar,
|
||||||
|
FileCode,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { TaskStatsBar, TaskStatusDropdown } from '@/components/session-detail/tasks';
|
import { TaskStatsBar, TaskStatusDropdown } from '@/components/session-detail/tasks';
|
||||||
import type { SessionMetadata, TaskData } from '@/types/store';
|
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';
|
import { bulkUpdateTaskStatus, updateTaskStatus } from '@/lib/api';
|
||||||
|
|
||||||
export interface TaskListTabProps {
|
// Extended task type with all possible fields from JSON
|
||||||
session: SessionMetadata;
|
interface ExtendedTask extends TaskData {
|
||||||
onTaskClick?: (task: TaskData) => void;
|
meta?: {
|
||||||
|
type?: string;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
context?: {
|
||||||
|
focus_paths?: string[];
|
||||||
|
acceptance?: string[];
|
||||||
|
depends_on?: string[];
|
||||||
|
};
|
||||||
|
flow_control?: FlowControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskListTabProps {
|
export interface TaskListTabProps {
|
||||||
@@ -52,62 +67,72 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
|||||||
// Get session path for API calls
|
// Get session path for API calls
|
||||||
const sessionPath = (session as any).path || session.session_id;
|
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 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;
|
if (targetTasks.length === 0) return;
|
||||||
|
|
||||||
setIsLoadingPending(true);
|
setIsLoadingPending(true);
|
||||||
|
// Optimistic update
|
||||||
|
setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'pending' as const })));
|
||||||
try {
|
try {
|
||||||
const taskIds = targetTasks.map((t) => t.task_id);
|
const taskIds = targetTasks.map((t) => t.task_id);
|
||||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'pending');
|
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'pending');
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
// Optimistic update - will be refreshed when parent re-renders
|
|
||||||
} else {
|
|
||||||
console.error('[TaskListTab] Failed to mark all as pending:', result.error);
|
console.error('[TaskListTab] Failed to mark all as pending:', result.error);
|
||||||
|
// Rollback on error
|
||||||
|
setLocalTasks(tasks);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[TaskListTab] Failed to mark all as pending:', error);
|
console.error('[TaskListTab] Failed to mark all as pending:', error);
|
||||||
|
setLocalTasks(tasks);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingPending(false);
|
setIsLoadingPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllInProgress = async () => {
|
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;
|
if (targetTasks.length === 0) return;
|
||||||
|
|
||||||
setIsLoadingInProgress(true);
|
setIsLoadingInProgress(true);
|
||||||
|
// Optimistic update
|
||||||
|
setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'in_progress' as const })));
|
||||||
try {
|
try {
|
||||||
const taskIds = targetTasks.map((t) => t.task_id);
|
const taskIds = targetTasks.map((t) => t.task_id);
|
||||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'in_progress');
|
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'in_progress');
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
// Optimistic update - will be refreshed when parent re-renders
|
|
||||||
} else {
|
|
||||||
console.error('[TaskListTab] Failed to mark all as in_progress:', result.error);
|
console.error('[TaskListTab] Failed to mark all as in_progress:', result.error);
|
||||||
|
setLocalTasks(tasks);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[TaskListTab] Failed to mark all as in_progress:', error);
|
console.error('[TaskListTab] Failed to mark all as in_progress:', error);
|
||||||
|
setLocalTasks(tasks);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingInProgress(false);
|
setIsLoadingInProgress(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllCompleted = async () => {
|
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;
|
if (targetTasks.length === 0) return;
|
||||||
|
|
||||||
setIsLoadingCompleted(true);
|
setIsLoadingCompleted(true);
|
||||||
|
// Optimistic update
|
||||||
|
setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'completed' as const })));
|
||||||
try {
|
try {
|
||||||
const taskIds = targetTasks.map((t) => t.task_id);
|
const taskIds = targetTasks.map((t) => t.task_id);
|
||||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'completed');
|
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'completed');
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
// Optimistic update - will be refreshed when parent re-renders
|
|
||||||
} else {
|
|
||||||
console.error('[TaskListTab] Failed to mark all as completed:', result.error);
|
console.error('[TaskListTab] Failed to mark all as completed:', result.error);
|
||||||
|
setLocalTasks(tasks);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[TaskListTab] Failed to mark all as completed:', error);
|
console.error('[TaskListTab] Failed to mark all as completed:', error);
|
||||||
|
setLocalTasks(tasks);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingCompleted(false);
|
setIsLoadingCompleted(false);
|
||||||
}
|
}
|
||||||
@@ -170,6 +195,32 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{localTasks.map((task, index) => {
|
{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<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'warning' | 'info' }> = {
|
||||||
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={task.task_id || index}
|
key={task.task_id || index}
|
||||||
@@ -177,22 +228,13 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
|||||||
onClick={() => onTaskClick?.(task as TaskData)}
|
onClick={() => onTaskClick?.(task as TaskData)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{/* Left: Task ID, Title, Description */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">
|
||||||
{task.task_id}
|
{task.task_id}
|
||||||
</span>
|
</span>
|
||||||
<TaskStatusDropdown
|
|
||||||
currentStatus={task.status as TaskStatus}
|
|
||||||
onStatusChange={(newStatus) => handleTaskStatusChange(task.task_id, newStatus)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
{task.priority && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{task.priority}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-medium text-foreground text-sm">
|
<h4 className="font-medium text-foreground text-sm">
|
||||||
{task.title || formatMessage({ id: 'sessionDetail.tasks.untitled' })}
|
{task.title || formatMessage({ id: 'sessionDetail.tasks.untitled' })}
|
||||||
@@ -202,18 +244,63 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
|||||||
{task.description}
|
{task.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{task.depends_on && task.depends_on.length > 0 && (
|
</div>
|
||||||
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
|
||||||
<Code className="h-3 w-3" />
|
{/* Right: Status and Meta info */}
|
||||||
<span>Depends on: {task.depends_on.join(', ')}</span>
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||||
|
{/* Row 1: Status dropdown */}
|
||||||
|
<TaskStatusDropdown
|
||||||
|
currentStatus={task.status as TaskStatus}
|
||||||
|
onStatusChange={(newStatus) => handleTaskStatusChange(task.task_id, newStatus)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Row 2: Meta info */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap justify-end text-xs text-muted-foreground">
|
||||||
|
{priority && (
|
||||||
|
<Badge variant={priority.variant} className="text-xs gap-1">
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
{priority.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{taskType && (
|
||||||
|
<span className="bg-muted px-1.5 py-0.5 rounded">{taskType}</span>
|
||||||
|
)}
|
||||||
|
{stepsCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Layers className="h-3 w-3" />
|
||||||
|
{stepsCount} {formatMessage({ id: 'sessionDetail.tasks.steps' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filesCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileCode className="h-3 w-3" />
|
||||||
|
{filesCount} {formatMessage({ id: 'sessionDetail.tasks.files' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dependsCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
{dependsCount} {formatMessage({ id: 'sessionDetail.tasks.deps' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Scope or Date */}
|
||||||
|
{(taskScope || task.created_at) && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{taskScope && (
|
||||||
|
<span className="bg-muted px-1.5 py-0.5 rounded">{taskScope}</span>
|
||||||
|
)}
|
||||||
|
{task.created_at && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(task.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{task.created_at && (
|
|
||||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{new Date(task.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function run(argv: string[]): void {
|
|||||||
.option('--host <host>', 'Server host to bind', '127.0.0.1')
|
.option('--host <host>', 'Server host to bind', '127.0.0.1')
|
||||||
.option('--no-browser', 'Start server without opening browser')
|
.option('--no-browser', 'Start server without opening browser')
|
||||||
.option('--frontend <type>', 'Frontend type: js, react, both', 'both')
|
.option('--frontend <type>', 'Frontend type: js, react, both', 'both')
|
||||||
|
.option('--new', 'Launch React frontend (shorthand for --frontend react)')
|
||||||
.action(viewCommand);
|
.action(viewCommand);
|
||||||
|
|
||||||
// Serve command (alias for view)
|
// Serve command (alias for view)
|
||||||
@@ -99,6 +100,7 @@ export function run(argv: string[]): void {
|
|||||||
.option('--host <host>', 'Server host to bind', '127.0.0.1')
|
.option('--host <host>', 'Server host to bind', '127.0.0.1')
|
||||||
.option('--no-browser', 'Start server without opening browser')
|
.option('--no-browser', 'Start server without opening browser')
|
||||||
.option('--frontend <type>', 'Frontend type: js, react, both', 'both')
|
.option('--frontend <type>', 'Frontend type: js, react, both', 'both')
|
||||||
|
.option('--new', 'Launch React frontend (shorthand for --frontend react)')
|
||||||
.action(serveCommand);
|
.action(serveCommand);
|
||||||
|
|
||||||
// Stop command
|
// Stop command
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ServeOptions {
|
|||||||
host?: string;
|
host?: string;
|
||||||
browser?: boolean;
|
browser?: boolean;
|
||||||
frontend?: 'js' | 'react' | 'both';
|
frontend?: 'js' | 'react' | 'both';
|
||||||
|
new?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +21,8 @@ interface ServeOptions {
|
|||||||
export async function serveCommand(options: ServeOptions): Promise<void> {
|
export async function serveCommand(options: ServeOptions): Promise<void> {
|
||||||
const port = Number(options.port) || 3456;
|
const port = Number(options.port) || 3456;
|
||||||
const host = options.host || '127.0.0.1';
|
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
|
// Validate project path
|
||||||
let initialPath = process.cwd();
|
let initialPath = process.cwd();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ViewOptions {
|
|||||||
host?: string;
|
host?: string;
|
||||||
browser?: boolean;
|
browser?: boolean;
|
||||||
frontend?: 'js' | 'react' | 'both';
|
frontend?: 'js' | 'react' | 'both';
|
||||||
|
new?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SwitchWorkspaceResult {
|
interface SwitchWorkspaceResult {
|
||||||
@@ -76,7 +77,8 @@ export async function viewCommand(options: ViewOptions): Promise<void> {
|
|||||||
const port = Number(options.port) || 3456;
|
const port = Number(options.port) || 3456;
|
||||||
const host = options.host || '127.0.0.1';
|
const host = options.host || '127.0.0.1';
|
||||||
const browserHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
|
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
|
// Resolve workspace path
|
||||||
let workspacePath = process.cwd();
|
let workspacePath = process.cwd();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
import type { RouteContext } from './types.js';
|
import type { RouteContext } from './types.js';
|
||||||
|
|
||||||
@@ -606,18 +607,18 @@ async function executeCliCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (child.stdout) {
|
if (child.stdout) {
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
output += data.toString();
|
output += data.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (child.stderr) {
|
if (child.stderr) {
|
||||||
child.stderr.on('data', (data) => {
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
errorOutput += data.toString();
|
errorOutput += data.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code: number | null) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -632,11 +633,11 @@ async function executeCliCommand(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err: Error) => {
|
||||||
resolve({
|
resolve({
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
error: (err as Error).message
|
error: err.message
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,6 +275,75 @@ const HOOK_TEMPLATES = {
|
|||||||
description: 'Confirm before changing file permissions (chmod, chown, icacls)',
|
description: 'Confirm before changing file permissions (chmod, chown, icacls)',
|
||||||
category: 'danger',
|
category: 'danger',
|
||||||
timeout: 5000
|
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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1126,6 +1126,7 @@ const i18n = {
|
|||||||
'hook.createTitle': 'Create Hook',
|
'hook.createTitle': 'Create Hook',
|
||||||
'hook.event': 'Hook Event',
|
'hook.event': 'Hook Event',
|
||||||
'hook.selectEvent': 'Select an event...',
|
'hook.selectEvent': 'Select an event...',
|
||||||
|
'hook.sessionStart': 'SessionStart - When session starts or resumes',
|
||||||
'hook.preToolUse': 'PreToolUse - Before a tool is executed',
|
'hook.preToolUse': 'PreToolUse - Before a tool is executed',
|
||||||
'hook.postToolUse': 'PostToolUse - After a tool completes',
|
'hook.postToolUse': 'PostToolUse - After a tool completes',
|
||||||
'hook.notification': 'Notification - On notifications',
|
'hook.notification': 'Notification - On notifications',
|
||||||
@@ -1154,6 +1155,16 @@ const i18n = {
|
|||||||
// Hook Quick Install Templates
|
// Hook Quick Install Templates
|
||||||
'hook.tpl.sessionContext': 'Session Context',
|
'hook.tpl.sessionContext': 'Session Context',
|
||||||
'hook.tpl.sessionContextDesc': 'Load cluster overview once at session start',
|
'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.codexlensSync': 'CodexLens Auto-Sync',
|
||||||
'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited',
|
'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited',
|
||||||
'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify',
|
'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify',
|
||||||
@@ -1174,6 +1185,9 @@ const i18n = {
|
|||||||
'hook.category.memory': 'memory',
|
'hook.category.memory': 'memory',
|
||||||
'hook.category.skill': 'skill',
|
'hook.category.skill': 'skill',
|
||||||
'hook.category.context': 'context',
|
'hook.category.context': 'context',
|
||||||
|
'hook.category.session': 'session',
|
||||||
|
'hook.category.monitoring': 'monitoring',
|
||||||
|
'hook.category.danger': 'danger',
|
||||||
|
|
||||||
// Hook Wizard Templates
|
// Hook Wizard Templates
|
||||||
'hook.wizard.memoryUpdate': 'Memory Update Hook',
|
'hook.wizard.memoryUpdate': 'Memory Update Hook',
|
||||||
@@ -3808,6 +3822,7 @@ const i18n = {
|
|||||||
'hook.createTitle': '创建钩子',
|
'hook.createTitle': '创建钩子',
|
||||||
'hook.event': '钩子事件',
|
'hook.event': '钩子事件',
|
||||||
'hook.selectEvent': '选择事件...',
|
'hook.selectEvent': '选择事件...',
|
||||||
|
'hook.sessionStart': 'SessionStart - 会话启动或恢复时',
|
||||||
'hook.preToolUse': 'PreToolUse - 工具执行前',
|
'hook.preToolUse': 'PreToolUse - 工具执行前',
|
||||||
'hook.postToolUse': 'PostToolUse - 工具完成后',
|
'hook.postToolUse': 'PostToolUse - 工具完成后',
|
||||||
'hook.notification': 'Notification - 通知时',
|
'hook.notification': 'Notification - 通知时',
|
||||||
@@ -3836,6 +3851,16 @@ const i18n = {
|
|||||||
// Hook Quick Install Templates
|
// Hook Quick Install Templates
|
||||||
'hook.tpl.sessionContext': 'Session 上下文',
|
'hook.tpl.sessionContext': 'Session 上下文',
|
||||||
'hook.tpl.sessionContextDesc': '会话启动时加载集群概览(仅触发一次)',
|
'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.codexlensSync': 'CodexLens 自动同步',
|
||||||
'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引',
|
'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引',
|
||||||
'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知',
|
'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知',
|
||||||
@@ -3856,6 +3881,9 @@ const i18n = {
|
|||||||
'hook.category.memory': '记忆',
|
'hook.category.memory': '记忆',
|
||||||
'hook.category.skill': '技能',
|
'hook.category.skill': '技能',
|
||||||
'hook.category.context': '上下文',
|
'hook.category.context': '上下文',
|
||||||
|
'hook.category.session': '会话',
|
||||||
|
'hook.category.monitoring': '监控',
|
||||||
|
'hook.category.danger': '危险防护',
|
||||||
|
|
||||||
// Hook Wizard Templates
|
// Hook Wizard Templates
|
||||||
'hook.wizard.memoryUpdate': '记忆更新钩子',
|
'hook.wizard.memoryUpdate': '记忆更新钩子',
|
||||||
|
|||||||
@@ -100,12 +100,9 @@ async function renderHookManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
${renderQuickInstallCard('session-context', t('hook.tpl.sessionContext'), t('hook.tpl.sessionContextDesc'), 'UserPromptSubmit', '')}
|
<!-- Session Hooks -->
|
||||||
${renderQuickInstallCard('codexlens-update', t('hook.tpl.codexlensSync'), t('hook.tpl.codexlensSyncDesc'), 'PostToolUse', 'Write|Edit')}
|
${renderQuickInstallCard('session-start-notify', t('hook.tpl.sessionStart'), t('hook.tpl.sessionStartDesc'), 'SessionStart', '')}
|
||||||
${renderQuickInstallCard('ccw-notify', t('hook.tpl.ccwDashboardNotify'), t('hook.tpl.ccwDashboardNotifyDesc'), 'PostToolUse', 'Write')}
|
${renderQuickInstallCard('session-state-watch', t('hook.tpl.sessionState'), t('hook.tpl.sessionStateDesc'), 'PostToolUse', 'Write|Edit')}
|
||||||
${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')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user