feat: Add CodexLens Manager Page with tabbed interface for managing CodexLens features

feat: Implement ConflictTab component to display conflict resolution decisions in session detail

feat: Create ImplPlanTab component to show implementation plan with modal viewer in session detail

feat: Develop ReviewTab component to display review findings by dimension in session detail

test: Add end-to-end tests for CodexLens Manager functionality including navigation, tab switching, and settings validation
This commit is contained in:
catlog22
2026-02-01 17:45:38 +08:00
parent 8dc115a894
commit d46406df4a
79 changed files with 11819 additions and 2455 deletions

View File

@@ -148,6 +148,8 @@ User Input → Quick Context Gather → ccw cli (Gemini/Qwen/Codex)
// - Read error file if path provided // - Read error file if path provided
// - Extract error patterns from description // - Extract error patterns from description
// - Identify likely affected files (basic grep) // - Identify likely affected files (basic grep)
// Note: CLI mode does not generate status.json (lightweight)
``` ```
2. **Execute CLI Analysis** (Phase 3) 2. **Execute CLI Analysis** (Phase 3)
@@ -299,6 +301,27 @@ User Input → Session Init → /workflow:debug-with-file
flags: { hotfix, autoYes } flags: { hotfix, autoYes }
} }
Write(`${sessionFolder}/mode-config.json`, JSON.stringify(modeConfig, null, 2)) Write(`${sessionFolder}/mode-config.json`, JSON.stringify(modeConfig, null, 2))
// Initialize status.json for hook tracking
const state = {
session_id: sessionId,
mode: "debug",
status: "running",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
bug_description: bug_description,
command_chain: [
{ index: 0, command: "Phase 1: Debug & Analysis", status: "running" },
{ index: 1, command: "Phase 2: Apply Fix from Debug Findings", status: "pending" },
{ index: 2, command: "Phase 3: Generate & Execute Tests", status: "pending" },
{ index: 3, command: "Phase 4: Generate Report", status: "pending" }
],
current_index: 0
}
Write(`${sessionFolder}/status.json`, JSON.stringify(state, null, 2))
// Output session ID for hook matching
console.log(`📋 Session Started: ${sessionId}`)
``` ```
2. **Start Debug** (Phase 3) 2. **Start Debug** (Phase 3)
@@ -373,12 +396,38 @@ User Input → Session Init → /workflow:test-fix-gen
1. **Session Initialization** (Phase 2) 1. **Session Initialization** (Phase 2)
```javascript ```javascript
const sessionId = `CCWD-${bugSlug}-${dateStr}`
const sessionFolder = `.workflow/.ccw-debug/${sessionId}`
bash(`mkdir -p ${sessionFolder}`)
const modeConfig = { const modeConfig = {
mode: "test", mode: "test",
original_input: bug_description, original_input: bug_description,
timestamp: getUtc8ISOString(), timestamp: getUtc8ISOString(),
flags: { hotfix, autoYes } flags: { hotfix, autoYes }
} }
Write(`${sessionFolder}/mode-config.json`, JSON.stringify(modeConfig, null, 2))
// Initialize status.json for hook tracking
const state = {
session_id: sessionId,
mode: "test",
status: "running",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
bug_description: bug_description,
command_chain: [
{ index: 0, command: "Phase 1: Generate Tests", status: "running" },
{ index: 1, command: "Phase 2: Execute & Fix Tests", status: "pending" },
{ index: 2, command: "Phase 3: Final Validation", status: "pending" },
{ index: 3, command: "Phase 4: Generate Report", status: "pending" }
],
current_index: 0
}
Write(`${sessionFolder}/status.json`, JSON.stringify(state, null, 2))
// Output session ID for hook matching
console.log(`📋 Session Started: ${sessionId}`)
``` ```
2. **Generate Tests** (Phase 3) 2. **Generate Tests** (Phase 3)
@@ -439,8 +488,32 @@ User Input → Session Init → Parallel execution:
**Execution Steps**: **Execution Steps**:
1. **Parallel Execution** (Phase 3) 1. **Session Initialization & Parallel Execution** (Phase 2-3)
```javascript ```javascript
const sessionId = `CCWD-${bugSlug}-${dateStr}`
const sessionFolder = `.workflow/.ccw-debug/${sessionId}`
bash(`mkdir -p ${sessionFolder}`)
// Initialize status.json for hook tracking
const state = {
session_id: sessionId,
mode: "bidirectional",
status: "running",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
bug_description: bug_description,
command_chain: [
{ index: 0, command: "Phase 1: Parallel Debug & Test", status: "running" },
{ index: 1, command: "Phase 2: Merge Findings", status: "pending" },
{ index: 2, command: "Phase 3: Generate Report", status: "pending" }
],
current_index: 0
}
Write(`${sessionFolder}/status.json`, JSON.stringify(state, null, 2))
// Output session ID for hook matching
console.log(`📋 Session Started: ${sessionId}`)
// Start debug // Start debug
const debugTask = Skill(skill="workflow:debug-with-file", args=`"${bug_description}"`) const debugTask = Skill(skill="workflow:debug-with-file", args=`"${bug_description}"`)
@@ -579,18 +652,24 @@ Arguments:
### Session State Management ### Session State Management
**Status JSON Location**: `.workflow/.ccw-debug/{session_id}/status.json`
**Status JSON Structure**:
```json ```json
{ {
"session_id": "CCWD-login-timeout-2025-01-27", "session_id": "CCWD-login-timeout-2025-01-27",
"mode": "debug|test|bidirectional", "mode": "debug|test|bidirectional|cli",
"status": "running|completed|failed|paused", "status": "running|completed|failed|paused",
"phases": { "created_at": "2025-01-27T10:30:00Z",
"phase_1": { "status": "completed", "timestamp": "..." }, "updated_at": "2025-01-27T10:35:00Z",
"phase_2": { "status": "in_progress", "timestamp": "..." }, "bug_description": "User login timeout after 30 seconds",
"phase_3": { "status": "pending" }, "command_chain": [
"phase_4": { "status": "pending" }, { "index": 0, "command": "Phase 1: Debug & Analysis", "status": "completed" },
"phase_5": { "status": "pending" } { "index": 1, "command": "Phase 2: Apply Fix from Debug Findings", "status": "in_progress" },
}, { "index": 2, "command": "Phase 3: Generate & Execute Tests", "status": "pending" },
{ "index": 3, "command": "Phase 4: Generate Report", "status": "pending" }
],
"current_index": 1,
"sub_sessions": { "sub_sessions": {
"debug_session": "DBG-...", "debug_session": "DBG-...",
"test_session": "WFS-test-..." "test_session": "WFS-test-..."
@@ -603,6 +682,13 @@ Arguments:
} }
``` ```
**Session ID Output**: When session starts, ccw-debug outputs:
```
📋 Session Started: CCWD-login-timeout-2025-01-27
```
This output is captured by hooks for status.json path matching.
--- ---
## Mode Selection Logic ## Mode Selection Logic

View File

@@ -327,49 +327,107 @@ async function getUserConfirmation(chain) {
--- ---
### Phase 4: Setup TODO Tracking ### Phase 4: Setup TODO Tracking & Status File
```javascript ```javascript
function setupTodoTracking(chain, workflow) { function setupTodoTracking(chain, workflow, analysis) {
const sessionId = `ccw-${Date.now()}`;
const stateDir = `.workflow/.ccw/${sessionId}`;
Bash(`mkdir -p "${stateDir}"`);
const todos = chain.map((step, i) => ({ const todos = chain.map((step, i) => ({
content: `CCW:${workflow}: [${i + 1}/${chain.length}] ${step.cmd}`, content: `CCW:${workflow}: [${i + 1}/${chain.length}] ${step.cmd}`,
status: i === 0 ? 'in_progress' : 'pending', status: i === 0 ? 'in_progress' : 'pending',
activeForm: `Executing ${step.cmd}` activeForm: `Executing ${step.cmd}`
})); }));
TodoWrite({ todos }); TodoWrite({ todos });
// Initialize status.json for hook tracking
const state = {
session_id: sessionId,
workflow: workflow,
status: 'running',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
analysis: analysis,
command_chain: chain.map((step, idx) => ({
index: idx,
command: step.cmd,
status: idx === 0 ? 'running' : 'pending'
})),
current_index: 0
};
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
return { sessionId, stateDir, state };
} }
``` ```
**Output**: `-> CCW:rapid: [1/3] /workflow:lite-plan | CCW:rapid: [2/3] /workflow:lite-execute | ...` **Output**:
- TODO: `-> CCW:rapid: [1/3] /workflow:lite-plan | CCW:rapid: [2/3] /workflow:lite-execute | ...`
- Status File: `.workflow/.ccw/{session_id}/status.json`
--- ---
### Phase 5: Execute Command Chain ### Phase 5: Execute Command Chain
```javascript ```javascript
async function executeCommandChain(chain, workflow) { async function executeCommandChain(chain, workflow, trackingState) {
let previousResult = null; let previousResult = null;
const { sessionId, stateDir, state } = trackingState;
for (let i = 0; i < chain.length; i++) { for (let i = 0; i < chain.length; i++) {
try { try {
// Update status: mark current as running
state.command_chain[i].status = 'running';
state.current_index = i;
state.updated_at = new Date().toISOString();
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
const fullCommand = assembleCommand(chain[i], previousResult); const fullCommand = assembleCommand(chain[i], previousResult);
const result = await Skill({ skill: fullCommand }); const result = await Skill({ skill: fullCommand });
previousResult = { ...result, success: true }; previousResult = { ...result, success: true };
// Update status: mark current as completed, next as running
state.command_chain[i].status = 'completed';
if (i + 1 < chain.length) {
state.command_chain[i + 1].status = 'running';
}
state.updated_at = new Date().toISOString();
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
updateTodoStatus(i, chain.length, workflow, 'completed'); updateTodoStatus(i, chain.length, workflow, 'completed');
} catch (error) { } catch (error) {
// Update status on error
state.command_chain[i].status = 'failed';
state.status = 'error';
state.updated_at = new Date().toISOString();
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
const action = await handleError(chain[i], error, i); const action = await handleError(chain[i], error, i);
if (action === 'retry') { if (action === 'retry') {
state.command_chain[i].status = 'pending';
state.status = 'running';
i--; // Retry i--; // Retry
} else if (action === 'abort') { } else if (action === 'abort') {
state.status = 'failed';
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
// 'skip' - continue // 'skip' - continue
state.status = 'running';
} }
} }
return { success: true, completed: chain.length }; // Mark workflow as completed
state.status = 'completed';
state.updated_at = new Date().toISOString();
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
return { success: true, completed: chain.length, sessionId };
} }
// Assemble full command with session/plan parameters // Assemble full command with session/plan parameters
@@ -434,16 +492,19 @@ Phase 3: User Confirmation (optional)
|-- Show pipeline visualization |-- Show pipeline visualization
+-- Allow adjustment +-- Allow adjustment
| |
Phase 4: Setup TODO Tracking Phase 4: Setup TODO Tracking & Status File
+-- Create todos with CCW prefix |-- Create todos with CCW prefix
+-- Initialize .workflow/.ccw/{session_id}/status.json
| |
Phase 5: Execute Command Chain Phase 5: Execute Command Chain
|-- For each command: |-- For each command:
| |-- Update status.json (current=running)
| |-- Assemble full command | |-- Assemble full command
| |-- Execute via Skill | |-- Execute via Skill
| |-- Update status.json (current=completed, next=running)
| |-- Update TODO status | |-- Update TODO status
| +-- Handle errors (retry/skip/abort) | +-- Handle errors (retry/skip/abort)
+-- Return workflow result +-- Mark status.json as completed
``` ```
--- ---
@@ -482,7 +543,9 @@ Phase 5: Execute Command Chain
## State Management ## State Management
**TodoWrite-Based Tracking**: All execution state tracked via TodoWrite with `CCW:` prefix. ### Dual Tracking System
**1. TodoWrite-Based Tracking** (UI Display): All execution state tracked via TodoWrite with `CCW:` prefix.
```javascript ```javascript
// Initial state // Initial state
@@ -500,7 +563,57 @@ todos = [
]; ];
``` ```
**vs ccw-coordinator**: Extensive state.json with task_id, status transitions, hook callbacks. **2. Status.json Tracking**: Persistent state file for workflow monitoring.
**Location**: `.workflow/.ccw/{session_id}/status.json`
**Structure**:
```json
{
"session_id": "ccw-1706123456789",
"workflow": "rapid",
"status": "running|completed|failed|error",
"created_at": "2025-02-01T10:30:00Z",
"updated_at": "2025-02-01T10:35:00Z",
"analysis": {
"goal": "Add user authentication",
"scope": ["auth"],
"constraints": [],
"task_type": "feature",
"complexity": "medium"
},
"command_chain": [
{
"index": 0,
"command": "/workflow:lite-plan",
"status": "completed"
},
{
"index": 1,
"command": "/workflow:lite-execute",
"status": "running"
},
{
"index": 2,
"command": "/workflow:test-cycle-execute",
"status": "pending"
}
],
"current_index": 1
}
```
**Status Values**:
- `running`: Workflow executing commands
- `completed`: All commands finished
- `failed`: User aborted or unrecoverable error
- `error`: Command execution failed (during error handling)
**Command Status Values**:
- `pending`: Not started
- `running`: Currently executing
- `completed`: Successfully finished
- `failed`: Execution failed
--- ---
@@ -527,20 +640,6 @@ todos = [
--- ---
## Type Comparison: ccw vs ccw-coordinator
| Aspect | ccw | ccw-coordinator |
|--------|-----|-----------------|
| **Type** | Main process (Skill) | External CLI (ccw cli + hook callbacks) |
| **Execution** | Synchronous blocking | Async background with hook completion |
| **Workflow** | Auto intent-based selection | Manual chain building |
| **Intent Analysis** | 5-phase clarity check | 3-phase requirement analysis |
| **State** | TodoWrite only (in-memory) | state.json + checkpoint/resume |
| **Error Handling** | Retry/skip/abort (interactive) | Retry/skip/abort (via AskUser) |
| **Use Case** | Auto workflow for any task | Manual orchestration, large chains |
---
## Usage ## Usage
```bash ```bash

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,530 +0,0 @@
---
description: Merge multiple planning/brainstorm/analysis outputs, resolve conflicts, and synthesize unified plan. Multi-team input aggregation and plan crystallization
argument-hint: "PATTERN=\"<plan pattern or topic>\" [--rule=consensus|priority|hierarchy] [--output=<path>] [--auto] [--verbose]"
---
# Codex Merge-Plans-With-File Prompt
## Overview
Plan aggregation and conflict resolution workflow. Takes multiple planning artifacts (brainstorm conclusions, analysis recommendations, quick-plans, implementation plans) and synthesizes them into a unified, conflict-resolved execution plan.
**Core workflow**: Load Sources → Parse Plans → Conflict Analysis → Arbitration → Unified Plan
**Key features**:
- **Multi-Source Support**: brainstorm, analysis, quick-plan, IMPL_PLAN, task JSONs
- **Conflict Detection**: Identify contradictions across all input plans
- **Resolution Rules**: consensus, priority-based, or hierarchical resolution
- **Unified Synthesis**: Single authoritative plan from multiple perspectives
- **Decision Tracking**: Full audit trail of conflicts and resolutions
## Target Pattern
**$PATTERN**
- `--rule`: Conflict resolution (consensus | priority | hierarchy) - consensus by default
- `--output`: Output directory (default: .workflow/.merged/{pattern})
- `--auto`: Auto-resolve conflicts using rule, skip confirmations
- `--verbose`: Include detailed conflict analysis
## Execution Process
```
Phase 1: Discovery & Loading
├─ Search for artifacts matching pattern
├─ Load synthesis.json, conclusions.json, IMPL_PLAN.md, task JSONs
├─ Parse into normalized task structure
└─ Validate completeness
Phase 2: Plan Normalization
├─ Convert all formats to common task representation
├─ Extract: tasks, dependencies, effort, risks
├─ Identify scope and boundaries
└─ Aggregate recommendations
Phase 3: Conflict Detection (Parallel)
├─ Architecture conflicts: different design approaches
├─ Task conflicts: overlapping or duplicated tasks
├─ Effort conflicts: different estimates
├─ Risk conflicts: different risk assessments
├─ Scope conflicts: different feature sets
└─ Generate conflict matrix
Phase 4: Conflict Resolution
├─ Analyze source rationale for each conflict
├─ Apply resolution rule (consensus / priority / hierarchy)
├─ Escalate unresolvable conflicts to user (unless --auto)
├─ Document decision rationale
└─ Generate resolutions.json
Phase 5: Plan Synthesis
├─ Merge task lists (deduplicate, combine insights)
├─ Integrate dependencies
├─ Consolidate effort and risk estimates
├─ Generate execution sequence
└─ Output unified-plan.json
Output:
├─ .workflow/.merged/{sessionId}/merge.md (process log)
├─ .workflow/.merged/{sessionId}/source-index.json (input sources)
├─ .workflow/.merged/{sessionId}/conflicts.json (conflict matrix)
├─ .workflow/.merged/{sessionId}/resolutions.json (decisions)
├─ .workflow/.merged/{sessionId}/unified-plan.json (for execution)
└─ .workflow/.merged/{sessionId}/unified-plan.md (human-readable)
```
## Implementation Details
### Phase 1: Discover & Load Sources
```javascript
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
const mergeSlug = "$PATTERN".toLowerCase()
.replace(/[*?]/g, '-')
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-')
.substring(0, 30)
const sessionId = `MERGE-${mergeSlug}-${getUtc8ISOString().substring(0, 10)}`
const sessionFolder = `.workflow/.merged/${sessionId}`
bash(`mkdir -p ${sessionFolder}`)
// Search paths for matching artifacts
const searchPaths = [
`.workflow/.brainstorm/*${$PATTERN}*/synthesis.json`,
`.workflow/.analysis/*${$PATTERN}*/conclusions.json`,
`.workflow/.planning/*${$PATTERN}*/synthesis.json`,
`.workflow/.plan/*${$PATTERN}*IMPL_PLAN.md`,
`.workflow/**/*${$PATTERN}*.json`
]
// Load and validate each source
const sourcePlans = []
for (const pattern of searchPaths) {
const matches = glob(pattern)
for (const path of matches) {
const plan = loadAndParsePlan(path)
if (plan?.tasks?.length > 0) {
sourcePlans.push({ path, type: inferType(path), plan })
}
}
}
```
### Phase 2: Normalize Plans
Convert all source formats to common structure:
```javascript
const normalizedPlans = sourcePlans.map((src, idx) => ({
index: idx,
source: src.path,
type: src.type,
metadata: {
title: src.plan.title || `Plan ${idx + 1}`,
topic: src.plan.topic,
complexity: src.plan.complexity_level || 'unknown'
},
tasks: src.plan.tasks.map(task => ({
id: `T${idx}-${task.id || task.title.substring(0, 20)}`,
title: task.title,
description: task.description,
type: task.type || inferTaskType(task),
priority: task.priority || 'normal',
effort: { estimated: task.effort_estimate, from_plan: idx },
risk: { level: task.risk_level || 'medium', from_plan: idx },
dependencies: task.dependencies || [],
source_plan_index: idx
}))
}))
```
### Phase 3: Parallel Conflict Detection
Launch parallel agents to detect and analyze conflicts:
```javascript
// Parallel conflict detection with CLI agents
const conflictPromises = []
// Agent 1: Detect effort and task conflicts
conflictPromises.push(
Bash({
command: `ccw cli -p "
PURPOSE: Detect effort conflicts and task duplicates across multiple plans
Success: Complete identification of conflicting estimates and duplicate tasks
TASK:
• Identify tasks with significantly different effort estimates (>50% variance)
• Detect duplicate/similar tasks across plans
• Analyze effort estimation reasoning
• Suggest resolution for each conflict
MODE: analysis
CONTEXT:
- Plan 1: ${JSON.stringify(normalizedPlans[0]?.tasks?.slice(0,3) || [], null, 2)}
- Plan 2: ${JSON.stringify(normalizedPlans[1]?.tasks?.slice(0,3) || [], null, 2)}
- [Additional plans...]
EXPECTED:
- Effort conflicts detected (task name, estimate in each plan, variance %)
- Duplicate task analysis (similar tasks, scope differences)
- Resolution recommendation for each conflict
- Confidence level for each detection
CONSTRAINTS: Focus on significant conflicts (>30% effort variance)
" --tool gemini --mode analysis`,
run_in_background: true
})
)
// Agent 2: Analyze architecture and scope conflicts
conflictPromises.push(
Bash({
command: \`ccw cli -p "
PURPOSE: Analyze architecture and scope conflicts across plans
Success: Clear identification of design approach differences and scope gaps
TASK:
• Identify different architectural approaches in plans
• Detect scope differences (features included/excluded)
• Analyze design philosophy conflicts
• Suggest approach to reconcile different visions
MODE: analysis
CONTEXT:
- Plan 1 architecture: \${normalizedPlans[0]?.metadata?.complexity || 'unknown'}
- Plan 2 architecture: \${normalizedPlans[1]?.metadata?.complexity || 'unknown'}
- Different design approaches detected: \${JSON.stringify(['approach1', 'approach2'])}
EXPECTED:
- Architecture conflicts identified (approach names and trade-offs)
- Scope conflicts (features/components in plan A but not B, vice versa)
- Design philosophy alignment/misalignment
- Recommendation for unified approach
- Pros/cons of each architectural approach
CONSTRAINTS: Consider both perspectives objectively
" --tool codex --mode analysis\`,
run_in_background: true
})
)
// Agent 3: Analyze risk assessment conflicts
conflictPromises.push(
Bash({
command: \`ccw cli -p "
PURPOSE: Analyze risk assessment conflicts across plans
Success: Unified risk assessment with conflict resolution
TASK:
• Identify tasks/areas with significantly different risk ratings
• Analyze risk assessment reasoning
• Detect missing risks in some plans
• Propose unified risk assessment
MODE: analysis
CONTEXT:
- Risk areas with disagreement: [list areas]
- Plan 1 risk ratings: [risk matrix]
- Plan 2 risk ratings: [risk matrix]
EXPECTED:
- Risk conflicts identified (area, plan A rating, plan B rating)
- Explanation of why assessments differ
- Missing risks analysis (important in one plan but not others)
- Unified risk rating recommendation
- Confidence level for each assessment
CONSTRAINTS: Be realistic in risk assessment, not pessimistic
" --tool claude --mode analysis\`,
run_in_background: true
})
)
// Agent 4: Synthesize conflicts into resolution strategy
conflictPromises.push(
Bash({
command: \`ccw cli -p "
PURPOSE: Synthesize all conflicts into unified resolution strategy
Success: Clear path to merge plans with informed trade-off decisions
TASK:
• Analyze all detected conflicts holistically
• Identify which conflicts are critical vs. non-critical
• Propose resolution for each conflict type
• Suggest unified approach that honors valid insights from all plans
MODE: analysis
CONTEXT:
- Total conflicts detected: [number]
- Conflict types: effort, architecture, scope, risk
- Resolution rule: \${resolutionRule}
- Plan importance: \${normalizedPlans.map(p => p.metadata.title).join(', ')}
EXPECTED:
- Conflict priority ranking (critical, important, minor)
- Recommended resolution for each conflict
- Rationale for each recommendation
- Potential issues with proposed resolution
- Fallback options if recommendation not accepted
- Overall merge strategy and sequencing
CONSTRAINTS: Aim for solution that maximizes learning from all perspectives
" --tool gemini --mode analysis\`,
run_in_background: true
})
)
// Wait for all conflict detection agents to complete
const [effortConflicts, archConflicts, riskConflicts, resolutionStrategy] =
await Promise.all(conflictPromises)
// Parse and consolidate all conflict findings
const allConflicts = {
effort: parseEffortConflicts(effortConflicts),
architecture: parseArchConflicts(archConflicts),
risk: parseRiskConflicts(riskConflicts),
strategy: parseResolutionStrategy(resolutionStrategy),
timestamp: getUtc8ISOString()
}
Write(\`\${sessionFolder}/conflicts.json\`, JSON.stringify(allConflicts, null, 2))
```
**Conflict Detection Workflow**:
| Agent | Conflict Type | Focus | Output |
|-------|--------------|--------|--------|
| Gemini | Effort & Tasks | Duplicate detection, estimate variance | Conflicts with variance %, resolution suggestions |
| Codex | Architecture & Scope | Design approach differences | Design conflicts, scope gaps, recommendations |
| Claude | Risk Assessment | Risk rating disagreements | Risk conflicts, missing risks, unified assessment |
| Gemini | Resolution Strategy | Holistic synthesis | Priority ranking, resolution path, trade-offs |
### Phase 4: Resolve Conflicts
**Rule: Consensus (default)**
- Use median/average of conflicting estimates
- Merge scope differences
- Document minority viewpoints
**Rule: Priority**
- First plan has highest authority
- Later plans supplement but don't override
**Rule: Hierarchy**
- User ranks plan importance
- Higher-ranked plan wins conflicts
```javascript
const resolutions = {}
if (rule === 'consensus') {
for (const conflict of conflicts.effort) {
resolutions[conflict.task] = {
resolved: calculateMedian(conflict.estimates),
method: 'consensus-median',
rationale: 'Used median of all estimates'
}
}
} else if (rule === 'priority') {
for (const conflict of conflicts.effort) {
const primary = conflict.estimates[0] // First plan
resolutions[conflict.task] = {
resolved: primary.value,
method: 'priority-based',
rationale: `Selected from plan ${primary.from_plan} (highest priority)`
}
}
} else if (rule === 'hierarchy') {
// Request user ranking if not --auto
const ranking = getUserPlanRanking(normalizedPlans)
// Apply hierarchy-based resolution
}
Write(`${sessionFolder}/resolutions.json`, JSON.stringify(resolutions, null, 2))
```
### Phase 5: Generate Unified Plan
```javascript
const unifiedPlan = {
session_id: sessionId,
merge_timestamp: getUtc8ISOString(),
summary: {
total_source_plans: sourcePlans.length,
original_tasks: allTasks.length,
merged_tasks: deduplicatedTasks.length,
conflicts_resolved: Object.keys(resolutions).length,
resolution_rule: rule
},
tasks: deduplicatedTasks.map(task => ({
id: task.id,
title: task.title,
description: task.description,
effort: task.resolved_effort,
risk: task.resolved_risk,
dependencies: task.merged_dependencies,
source_plans: task.contributing_plans
})),
execution_sequence: topologicalSort(tasks),
critical_path: identifyCriticalPath(tasks),
risks: aggregateRisks(tasks),
success_criteria: aggregateCriteria(tasks)
}
Write(`${sessionFolder}/unified-plan.json`, JSON.stringify(unifiedPlan, null, 2))
```
### Phase 6: Generate Human-Readable Plan
```markdown
# Merged Planning Session
**Session ID**: ${sessionId}
**Pattern**: $PATTERN
**Created**: ${timestamp}
---
## Merge Summary
**Source Plans**: ${summary.total_source_plans}
**Original Tasks**: ${summary.original_tasks}
**Merged Tasks**: ${summary.merged_tasks}
**Conflicts Resolved**: ${summary.conflicts_resolved}
**Resolution Method**: ${summary.resolution_rule}
---
## Unified Task List
${tasks.map((task, i) => `
${i+1}. **${task.id}: ${task.title}**
- Effort: ${task.effort}
- Risk: ${task.risk}
- From plans: ${task.source_plans.join(', ')}
`).join('\n')}
---
## Execution Sequence
**Critical Path**: ${critical_path.join(' → ')}
---
## Conflict Resolution Report
${Object.entries(resolutions).map(([key, res]) => `
- **${key}**: ${res.rationale}
`).join('\n')}
---
## Next Steps
**Execute**:
\`\`\`
/workflow:unified-execute-with-file -p ${sessionFolder}/unified-plan.json
\`\`\`
```
## Session Folder Structure
```
.workflow/.merged/{sessionId}/
├── merge.md # Process log
├── source-index.json # All input sources
├── conflicts.json # Detected conflicts
├── resolutions.json # How resolved
├── unified-plan.json # Merged plan (for execution)
└── unified-plan.md # Human-readable
```
## Resolution Rules Comparison
| Rule | Method | Best For | Tradeoff |
|------|--------|----------|----------|
| **Consensus** | Median/average | Similar-quality inputs | May miss extremes |
| **Priority** | First wins | Clear authority order | Discards alternatives |
| **Hierarchy** | User-ranked | Mixed stakeholders | Needs user input |
## Input Format Support
| Source Type | Detection Pattern | Parsing |
|-------------|-------------------|---------|
| Brainstorm | `.brainstorm/*/synthesis.json` | Top ideas → tasks |
| Analysis | `.analysis/*/conclusions.json` | Recommendations → tasks |
| Quick-Plan | `.planning/*/synthesis.json` | Direct task list |
| IMPL_PLAN | `*IMPL_PLAN.md` | Markdown → tasks |
| Task JSON | `*.json` with `tasks` | Direct mapping |
## Error Handling
| Situation | Action |
|-----------|--------|
| No plans found | List available plans, suggest search terms |
| Incompatible format | Skip, continue with others |
| Circular dependencies | Alert user, suggest manual review |
| Unresolvable conflict | Require user decision (unless --auto) |
## Integration Flow
```
Brainstorm Sessions / Analyses / Plans
├─ synthesis.json (session 1)
├─ conclusions.json (session 2)
├─ synthesis.json (session 3)
merge-plans-with-file
├─ unified-plan.json
unified-execute-with-file
Implementation
```
## Usage Patterns
**Pattern 1: Merge all auth-related plans**
```
PATTERN="authentication" --rule=consensus --auto
→ Finds all auth plans
→ Merges with consensus method
```
**Pattern 2: Prioritized merge**
```
PATTERN="payment" --rule=priority
→ First plan has authority
→ Others supplement
```
**Pattern 3: Team input merge**
```
PATTERN="feature-*" --rule=hierarchy
→ Asks for plan ranking
→ Applies hierarchy resolution
```
---
**Now execute merge-plans-with-file for pattern**: $PATTERN

View File

@@ -0,0 +1,292 @@
// ========================================
// CodexLens Advanced Tab
// ========================================
// Advanced settings including .env editor and ignore patterns
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw, AlertTriangle, FileCode } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Badge } from '@/components/ui/Badge';
import { useCodexLensEnv, useUpdateCodexLensEnv } from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
interface AdvancedTabProps {
enabled?: boolean;
}
interface FormErrors {
env?: string;
}
export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const {
raw,
env,
settings,
isLoading: isLoadingEnv,
refetch,
} = useCodexLensEnv({ enabled });
const { updateEnv, isUpdating } = useUpdateCodexLensEnv();
// Form state
const [envInput, setEnvInput] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [hasChanges, setHasChanges] = useState(false);
const [showWarning, setShowWarning] = useState(false);
// Initialize form from env
useEffect(() => {
if (raw !== undefined) {
setEnvInput(raw);
setErrors({});
setHasChanges(false);
setShowWarning(false);
}
}, [raw]);
const handleEnvChange = (value: string) => {
setEnvInput(value);
// Check if there are changes
if (raw !== undefined) {
setHasChanges(value !== raw);
setShowWarning(value !== raw);
}
if (errors.env) {
setErrors((prev) => ({ ...prev, env: undefined }));
}
};
const parseEnvVariables = (text: string): Record<string, string> => {
const envObj: Record<string, string> = {};
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
const [key, ...valParts] = trimmed.split('=');
const val = valParts.join('=');
if (key) {
envObj[key.trim()] = val.trim();
}
}
}
return envObj;
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
const parsed = parseEnvVariables(envInput);
// Check for invalid variable names
const invalidKeys = Object.keys(parsed).filter(
(key) => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)
);
if (invalidKeys.length > 0) {
newErrors.env = formatMessage(
{ id: 'codexlens.advanced.validation.invalidKeys' },
{ keys: invalidKeys.join(', ') }
);
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validateForm()) {
return;
}
try {
const parsed = parseEnvVariables(envInput);
const result = await updateEnv({ env: parsed });
if (result.success) {
success(
formatMessage({ id: 'codexlens.advanced.saveSuccess' }),
result.message || formatMessage({ id: 'codexlens.advanced.envUpdated' })
);
refetch();
setShowWarning(false);
} else {
showError(
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
result.message || formatMessage({ id: 'codexlens.advanced.saveError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.advanced.unknownError' })
);
}
};
const handleReset = () => {
if (raw !== undefined) {
setEnvInput(raw);
setErrors({});
setHasChanges(false);
setShowWarning(false);
}
};
const isLoading = isLoadingEnv;
// Get current env variables as array for display
const currentEnvVars = env
? Object.entries(env).map(([key, value]) => ({ key, value }))
: [];
// Get settings variables
const settingsVars = settings
? Object.entries(settings).map(([key, value]) => ({ key, value }))
: [];
return (
<div className="space-y-6">
{/* Sensitivity Warning Card */}
{showWarning && (
<Card className="p-4 bg-warning/10 border-warning/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-warning-foreground">
{formatMessage({ id: 'codexlens.advanced.warningTitle' })}
</h4>
<p className="text-xs text-warning-foreground/80 mt-1">
{formatMessage({ id: 'codexlens.advanced.warningMessage' })}
</p>
</div>
</div>
</Card>
)}
{/* Current Variables Summary */}
{(currentEnvVars.length > 0 || settingsVars.length > 0) && (
<Card className="p-4 bg-muted/30">
<h4 className="text-sm font-medium text-foreground mb-3">
{formatMessage({ id: 'codexlens.advanced.currentVars' })}
</h4>
<div className="space-y-3">
{settingsVars.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.settingsVars' })}
</p>
<div className="flex flex-wrap gap-2">
{settingsVars.map(({ key }) => (
<Badge key={key} variant="outline" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
</div>
)}
{currentEnvVars.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.customVars' })}
</p>
<div className="flex flex-wrap gap-2">
{currentEnvVars.map(({ key }) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
</div>
)}
</div>
</Card>
)}
{/* Environment Variables Editor */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'codexlens.advanced.envEditor' })}
</h3>
</div>
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.advanced.envFile' })}: .env
</Badge>
</div>
<div className="space-y-4">
{/* Env Textarea */}
<div className="space-y-2">
<Label htmlFor="env-input">
{formatMessage({ id: 'codexlens.advanced.envContent' })}
</Label>
<Textarea
id="env-input"
value={envInput}
onChange={(e) => handleEnvChange(e.target.value)}
placeholder={formatMessage({ id: 'codexlens.advanced.envPlaceholder' })}
className={cn(
'min-h-[300px] font-mono text-sm',
errors.env && 'border-destructive focus-visible:ring-destructive'
)}
disabled={isLoading}
/>
{errors.env && (
<p className="text-sm text-destructive">{errors.env}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.advanced.envHint' })}
</p>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={isLoading || isUpdating || !hasChanges}
>
<Save className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')} />
{isUpdating
? formatMessage({ id: 'codexlens.advanced.saving' })
: formatMessage({ id: 'codexlens.advanced.save' })
}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || !hasChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.advanced.reset' })}
</Button>
</div>
</div>
</Card>
{/* Help Card */}
<Card className="p-4 bg-info/10 border-info/20">
<h4 className="text-sm font-medium text-info-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.helpTitle' })}
</h4>
<ul className="text-xs text-info-foreground/80 space-y-1">
<li> {formatMessage({ id: 'codexlens.advanced.helpComment' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpFormat' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpQuotes' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpRestart' })}</li>
</ul>
</Card>
</div>
);
}
export default AdvancedTab;

View File

@@ -0,0 +1,293 @@
// ========================================
// CodexLens GPU Selector
// ========================================
// GPU detection, listing, and selection component
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Cpu, Search, Check, X, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useCodexLensGpu, useSelectGpu } from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import type { CodexLensGpuDevice } from '@/lib/api';
interface GpuSelectorProps {
enabled?: boolean;
compact?: boolean;
}
export function GpuSelector({ enabled = true, compact = false }: GpuSelectorProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const {
supported,
devices,
selectedDeviceId,
isLoadingDetect,
isLoadingList,
refetch,
} = useCodexLensGpu({ enabled });
const { selectGpu, resetGpu, isSelecting, isResetting } = useSelectGpu();
const [isDetecting, setIsDetecting] = useState(false);
const isLoading = isLoadingDetect || isLoadingList || isDetecting;
const handleDetect = async () => {
setIsDetecting(true);
try {
await refetch();
success(
formatMessage({ id: 'codexlens.gpu.detectSuccess' }),
formatMessage({ id: 'codexlens.gpu.detectComplete' }, { count: devices?.length ?? 0 })
);
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.detectFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.detectError' })
);
} finally {
setIsDetecting(false);
}
};
const handleSelect = async (deviceId: string | number) => {
try {
const result = await selectGpu(deviceId);
if (result.success) {
success(
formatMessage({ id: 'codexlens.gpu.selectSuccess' }),
result.message || formatMessage({ id: 'codexlens.gpu.gpuSelected' })
);
refetch();
} else {
showError(
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
result.message || formatMessage({ id: 'codexlens.gpu.selectError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
);
}
};
const handleReset = async () => {
try {
const result = await resetGpu();
if (result.success) {
success(
formatMessage({ id: 'codexlens.gpu.resetSuccess' }),
result.message || formatMessage({ id: 'codexlens.gpu.gpuReset' })
);
refetch();
} else {
showError(
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
result.message || formatMessage({ id: 'codexlens.gpu.resetError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
);
}
};
if (compact) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.status' })}:
</span>
{supported !== false ? (
selectedDeviceId !== undefined ? (
<Badge variant="success" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.enabled' })}
</Badge>
) : (
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.available' })}
</Badge>
)
) : (
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.unavailable' })}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDetect}
disabled={isLoading}
>
<Search className={cn('w-3 h-3 mr-1', isLoading && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.detect' })}
</Button>
{selectedDeviceId !== undefined && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
>
<X className="w-3 h-3 mr-1" />
{formatMessage({ id: 'codexlens.gpu.reset' })}
</Button>
)}
</div>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{/* Header Card */}
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
supported !== false ? 'bg-success/10' : 'bg-secondary'
)}>
<Cpu className={cn(
'w-5 h-5',
supported !== false ? 'text-success' : 'text-muted-foreground'
)} />
</div>
<div>
<h4 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'codexlens.gpu.title' })}
</h4>
<p className="text-xs text-muted-foreground">
{supported !== false
? formatMessage({ id: 'codexlens.gpu.supported' })
: formatMessage({ id: 'codexlens.gpu.notSupported' })
}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDetect}
disabled={isLoading}
>
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.detect' })}
</Button>
{selectedDeviceId !== undefined && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
>
<RefreshCw className={cn('w-4 h-4 mr-2', isResetting && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.reset' })}
</Button>
)}
</div>
</div>
</Card>
{/* Device List */}
{devices && devices.length > 0 ? (
<div className="space-y-2">
{devices.map((device) => {
const deviceId = device.device_id ?? device.index;
return (
<DeviceCard
key={deviceId}
device={device}
isSelected={deviceId === selectedDeviceId}
onSelect={() => handleSelect(deviceId)}
isSelecting={isSelecting}
/>
);
})}
</div>
) : (
<Card className="p-8 text-center">
<Cpu className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
{supported !== false
? formatMessage({ id: 'codexlens.gpu.noDevices' })
: formatMessage({ id: 'codexlens.gpu.notAvailable' })
}
</p>
</Card>
)}
</div>
);
}
interface DeviceCardProps {
device: CodexLensGpuDevice;
isSelected: boolean;
onSelect: () => void;
isSelecting: boolean;
}
function DeviceCard({ device, isSelected, onSelect, isSelecting }: DeviceCardProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn(
'p-4 transition-colors',
isSelected && 'border-primary bg-primary/5'
)}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h5 className="text-sm font-medium text-foreground">
{device.name || formatMessage({ id: 'codexlens.gpu.unknownDevice' })}
</h5>
{isSelected && (
<Badge variant="success" className="text-xs">
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'codexlens.gpu.selected' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.type' })}: {device.type === 'discrete' ? '独立显卡' : '集成显卡'}
</p>
{device.memory?.total && (
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.memory' })}: {(device.memory.total / 1024).toFixed(1)} GB
</p>
)}
</div>
<Button
variant={isSelected ? 'outline' : 'default'}
size="sm"
onClick={onSelect}
disabled={isSelected || isSelecting}
>
{isSelected
? formatMessage({ id: 'codexlens.gpu.active' })
: formatMessage({ id: 'codexlens.gpu.select' })
}
</Button>
</div>
</Card>
);
}
export default GpuSelector;

View File

@@ -0,0 +1,231 @@
// ========================================
// Model Card Component
// ========================================
// Individual model display card with actions
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Download,
Trash2,
Package,
HardDrive,
X,
Loader2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
import { Input } from '@/components/ui/Input';
import type { CodexLensModel } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface ModelCardProps {
model: CodexLensModel;
isDownloading?: boolean;
downloadProgress?: number;
isDeleting?: boolean;
onDownload: (profile: string) => void;
onDelete: (profile: string) => void;
onCancelDownload?: () => void;
}
// ========== Helper Functions ==========
function getModelTypeVariant(type: 'embedding' | 'reranker'): 'default' | 'secondary' {
return type === 'embedding' ? 'default' : 'secondary';
}
function formatSize(size?: string): string {
if (!size) return '-';
return size;
}
// ========== Component ==========
export function ModelCard({
model,
isDownloading = false,
downloadProgress = 0,
isDeleting = false,
onDownload,
onDelete,
onCancelDownload,
}: ModelCardProps) {
const { formatMessage } = useIntl();
const handleDownload = () => {
onDownload(model.profile);
};
const handleDelete = () => {
if (confirm(formatMessage({ id: 'codexlens.models.deleteConfirm' }, { modelName: model.name }))) {
onDelete(model.profile);
}
};
return (
<Card className={cn('overflow-hidden', !model.installed && 'opacity-80')}>
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
'p-2 rounded-lg flex-shrink-0',
model.installed ? 'bg-success/10' : 'bg-muted'
)}>
{model.installed ? (
<HardDrive className="w-4 h-4 text-success" />
) : (
<Package className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{model.name}
</span>
<Badge
variant={getModelTypeVariant(model.type)}
className="text-xs flex-shrink-0"
>
{model.type}
</Badge>
<Badge
variant={model.installed ? 'success' : 'outline'}
className="text-xs flex-shrink-0"
>
{model.installed
? formatMessage({ id: 'codexlens.models.status.downloaded' })
: formatMessage({ id: 'codexlens.models.status.available' })
}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>Backend: {model.backend}</span>
<span>Size: {formatSize(model.size)}</span>
</div>
{model.cache_path && (
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
{model.cache_path}
</p>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
{isDownloading ? (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onCancelDownload}
title={formatMessage({ id: 'codexlens.models.actions.cancel' })}
>
<X className="w-4 h-4" />
</Button>
) : model.installed ? (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDelete}
disabled={isDeleting}
title={formatMessage({ id: 'codexlens.models.actions.delete' })}
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={handleDownload}
title={formatMessage({ id: 'codexlens.models.actions.download' })}
>
<Download className="w-4 h-4 mr-1" />
<span className="text-xs">{formatMessage({ id: 'codexlens.models.actions.download' })}</span>
</Button>
)}
</div>
</div>
{/* Download Progress */}
{isDownloading && (
<div className="mt-3 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.models.downloading' })}
</span>
<span className="font-medium">{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
</div>
)}
</div>
</Card>
);
}
// ========== Custom Model Input ==========
export interface CustomModelInputProps {
isDownloading: boolean;
onDownload: (modelName: string, modelType: 'embedding' | 'reranker') => void;
}
export function CustomModelInput({ isDownloading, onDownload }: CustomModelInputProps) {
const { formatMessage } = useIntl();
const [modelName, setModelName] = useState('');
const [modelType, setModelType] = useState<'embedding' | 'reranker'>('embedding');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (modelName.trim()) {
onDownload(modelName.trim(), modelType);
setModelName('');
}
};
return (
<Card className="p-4 bg-primary/5 border-primary/20">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<Package className="w-4 h-4 text-primary" />
{formatMessage({ id: 'codexlens.models.custom.title' })}
</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex gap-2">
<Input
placeholder={formatMessage({ id: 'codexlens.models.custom.placeholder' })}
value={modelName}
onChange={(e) => setModelName(e.target.value)}
disabled={isDownloading}
className="flex-1"
/>
<select
value={modelType}
onChange={(e) => setModelType(e.target.value as 'embedding' | 'reranker')}
disabled={isDownloading}
className="px-3 py-2 text-sm rounded-md border border-input bg-background"
>
<option value="embedding">{formatMessage({ id: 'codexlens.models.types.embedding' })}</option>
<option value="reranker">{formatMessage({ id: 'codexlens.models.types.reranker' })}</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.models.custom.description' })}
</p>
</form>
</Card>
);
}
export default ModelCard;

View File

@@ -0,0 +1,396 @@
// ========================================
// Models Tab Component Tests
// ========================================
// Tests for CodexLens Models Tab component
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { ModelsTab } from './ModelsTab';
import type { CodexLensModel } from '@/lib/api';
// Mock hooks - use importOriginal to preserve all exports
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensModels: vi.fn(),
useCodexLensMutations: vi.fn(),
};
});
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
const mockModels: CodexLensModel[] = [
{
profile: 'embedding1',
name: 'BAAI/bge-small-en-v1.5',
type: 'embedding',
backend: 'onnx',
installed: true,
cache_path: '/cache/embedding1',
},
{
profile: 'reranker1',
name: 'BAAI/bge-reranker-v2-m3',
type: 'reranker',
backend: 'onnx',
installed: false,
cache_path: '/cache/reranker1',
},
{
profile: 'embedding2',
name: 'sentence-transformers/all-MiniLM-L6-v2',
type: 'embedding',
backend: 'torch',
installed: false,
cache_path: '/cache/embedding2',
},
];
const mockMutations = {
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdatingConfig: false,
bootstrap: vi.fn().mockResolvedValue({ success: true }),
isBootstrapping: false,
uninstall: vi.fn().mockResolvedValue({ success: true }),
isUninstalling: false,
downloadModel: vi.fn().mockResolvedValue({ success: true }),
downloadCustomModel: vi.fn().mockResolvedValue({ success: true }),
isDownloading: false,
deleteModel: vi.fn().mockResolvedValue({ success: true }),
isDeleting: false,
updateEnv: vi.fn().mockResolvedValue({ success: true, env: {}, settings: {}, raw: '' }),
isUpdatingEnv: false,
selectGpu: vi.fn().mockResolvedValue({ success: true }),
resetGpu: vi.fn().mockResolvedValue({ success: true }),
isSelectingGpu: false,
updatePatterns: vi.fn().mockResolvedValue({ patterns: [], extensionFilters: [], defaults: {} }),
isUpdatingPatterns: false,
isMutating: false,
};
describe('ModelsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should render search input', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByPlaceholderText(/Search models/i)).toBeInTheDocument();
});
it('should render filter buttons with counts', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText(/All/)).toBeInTheDocument();
expect(screen.getByText(/Embedding Models/)).toBeInTheDocument();
expect(screen.getByText(/Reranker Models/)).toBeInTheDocument();
expect(screen.getByText(/Downloaded/)).toBeInTheDocument();
expect(screen.getByText(/Available/)).toBeInTheDocument();
});
it('should render model list', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.getByText('sentence-transformers/all-MiniLM-L6-v2')).toBeInTheDocument();
});
it('should filter models by search query', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const searchInput = screen.getByPlaceholderText(/Search models/i);
await user.type(searchInput, 'bge');
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('sentence-transformers/all-MiniLM-L6-v2')).not.toBeInTheDocument();
});
it('should filter by embedding type', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const embeddingButton = screen.getByText(/Embedding Models/i);
await user.click(embeddingButton);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
});
it('should filter by reranker type', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const rerankerButton = screen.getByText(/Reranker Models/i);
await user.click(rerankerButton);
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
});
it('should filter by downloaded status', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const downloadedButton = screen.getByText(/Downloaded/i);
await user.click(downloadedButton);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
});
it('should filter by available status', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const availableButton = screen.getByText(/Available/i);
await user.click(availableButton);
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
});
it('should call downloadModel when download clicked', async () => {
const downloadModel = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
...mockMutations,
downloadModel,
});
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
// Filter to show available models
const availableButton = screen.getByText(/Available/i);
await user.click(availableButton);
const downloadButton = screen.getAllByText(/Download/i)[0];
await user.click(downloadButton);
await waitFor(() => {
expect(downloadModel).toHaveBeenCalled();
});
});
it('should refresh models on refresh button click', async () => {
const refetch = vi.fn();
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch,
});
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
expect(refetch).toHaveBeenCalledOnce();
});
});
describe('when not installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should show not installed message', () => {
render(<ModelsTab installed={false} />);
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
expect(screen.getByText(/Please install CodexLens to use model management features/i)).toBeInTheDocument();
});
});
describe('loading states', () => {
it('should show loading state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
});
describe('empty states', () => {
it('should show empty state when no models', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
expect(screen.getByText(/Try adjusting your search or filter criteria/i)).toBeInTheDocument();
});
it('should show empty state when search returns no results', async () => {
const user = userEvent.setup();
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
const searchInput = screen.getByPlaceholderText(/Search models/i);
await user.type(searchInput, 'nonexistent-model');
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
});
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should display translated text', () => {
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByPlaceholderText(/搜索模型/i)).toBeInTheDocument();
expect(screen.getByText(/筛选/i)).toBeInTheDocument();
expect(screen.getByText(/全部/i)).toBeInTheDocument();
expect(screen.getByText(/嵌入模型/i)).toBeInTheDocument();
expect(screen.getByText(/重排序模型/i)).toBeInTheDocument();
expect(screen.getByText(/已下载/i)).toBeInTheDocument();
expect(screen.getByText(/可用/i)).toBeInTheDocument();
expect(screen.getByText(/刷新/i)).toBeInTheDocument();
});
it('should translate empty state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByText(/没有找到模型/i)).toBeInTheDocument();
expect(screen.getByText(/尝试调整搜索或筛选条件/i)).toBeInTheDocument();
});
it('should translate not installed state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={false} />, { locale: 'zh' });
expect(screen.getByText(/CodexLens 未安装/i)).toBeInTheDocument();
});
});
describe('custom model input', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should render custom model input section', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText(/Custom Model/i)).toBeInTheDocument();
});
it('should translate custom model section in Chinese', () => {
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByText(/自定义模型/i)).toBeInTheDocument();
});
});
describe('error handling', () => {
it('should handle API errors gracefully', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: new Error('API Error'),
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
// Component should still render despite error
expect(screen.getByText(/BAAI\/bge-small-en-v1.5/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,283 @@
// ========================================
// Models Tab Component
// ========================================
// Model management tab with list, search, and download actions
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Search,
RefreshCw,
Package,
Filter,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { ModelCard, CustomModelInput } from './ModelCard';
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
import type { CodexLensModel } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
type FilterType = 'all' | 'embedding' | 'reranker' | 'downloaded' | 'available';
// ========== Helper Functions ==========
function filterModels(models: CodexLensModel[], filter: FilterType, search: string): CodexLensModel[] {
let filtered = models;
// Apply type/status filter
if (filter === 'embedding') {
filtered = filtered.filter(m => m.type === 'embedding');
} else if (filter === 'reranker') {
filtered = filtered.filter(m => m.type === 'reranker');
} else if (filter === 'downloaded') {
filtered = filtered.filter(m => m.installed);
} else if (filter === 'available') {
filtered = filtered.filter(m => !m.installed);
}
// Apply search filter
if (search.trim()) {
const query = search.toLowerCase();
filtered = filtered.filter(m =>
m.name.toLowerCase().includes(query) ||
m.profile.toLowerCase().includes(query) ||
m.backend.toLowerCase().includes(query)
);
}
return filtered;
}
// ========== Component ==========
export interface ModelsTabProps {
installed?: boolean;
}
export function ModelsTab({ installed = false }: ModelsTabProps) {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<FilterType>('all');
const [downloadingProfile, setDownloadingProfile] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState(0);
const {
models,
isLoading,
refetch,
} = useCodexLensModels({
enabled: installed,
});
const {
downloadModel,
downloadCustomModel,
deleteModel,
isDownloading,
isDeleting,
} = useCodexLensMutations();
// Filter models based on search and filter
const filteredModels = useMemo(() => {
if (!models) return [];
return filterModels(models, filterType, searchQuery);
}, [models, filterType, searchQuery]);
// Count models by type and status
const stats = useMemo(() => {
if (!models) return null;
return {
total: models.length,
embedding: models.filter(m => m.type === 'embedding').length,
reranker: models.filter(m => m.type === 'reranker').length,
downloaded: models.filter(m => m.installed).length,
available: models.filter(m => !m.installed).length,
};
}, [models]);
// Handle model download
const handleDownload = async (profile: string) => {
setDownloadingProfile(profile);
setDownloadProgress(0);
// Simulate progress for demo (in real implementation, use WebSocket or polling)
const progressInterval = setInterval(() => {
setDownloadProgress(prev => {
if (prev >= 95) {
clearInterval(progressInterval);
return 95;
}
return prev + 5;
});
}, 500);
try {
const result = await downloadModel(profile);
if (result.success) {
setDownloadProgress(100);
setTimeout(() => {
setDownloadingProfile(null);
setDownloadProgress(0);
refetch();
}, 500);
} else {
setDownloadingProfile(null);
setDownloadProgress(0);
}
} catch (error) {
setDownloadingProfile(null);
setDownloadProgress(0);
} finally {
clearInterval(progressInterval);
}
};
// Handle custom model download
const handleCustomDownload = async (modelName: string, modelType: 'embedding' | 'reranker') => {
try {
const result = await downloadCustomModel(modelName, modelType);
if (result.success) {
refetch();
}
} catch (error) {
console.error('Failed to download custom model:', error);
}
};
// Handle model delete
const handleDelete = async (profile: string) => {
const result = await deleteModel(profile);
if (result.success) {
refetch();
}
};
// Filter buttons
const filterButtons: Array<{ type: FilterType; label: string; count: number | undefined }> = [
{ type: 'all', label: formatMessage({ id: 'codexlens.models.filters.all' }), count: stats?.total },
{ type: 'embedding', label: formatMessage({ id: 'codexlens.models.types.embedding' }), count: stats?.embedding },
{ type: 'reranker', label: formatMessage({ id: 'codexlens.models.types.reranker' }), count: stats?.reranker },
{ type: 'downloaded', label: formatMessage({ id: 'codexlens.models.status.downloaded' }), count: stats?.downloaded },
{ type: 'available', label: formatMessage({ id: 'codexlens.models.status.available' }), count: stats?.available },
];
if (!installed) {
return (
<Card className="p-12 text-center">
<Package className="w-16 h-16 mx-auto text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'codexlens.models.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.models.notInstalled.description' })}
</p>
</Card>
);
}
return (
<div className="space-y-4">
{/* Header with Search and Actions */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'codexlens.models.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={cn('w-4 h-4 mr-1', isLoading && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</div>
</div>
</Card>
{/* Stats and Filters */}
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'codexlens.models.filters.label' })}
</span>
</div>
<div className="flex flex-wrap gap-2">
{filterButtons.map(({ type, label, count }) => (
<Button
key={type}
variant={filterType === type ? 'default' : 'outline'}
size="sm"
onClick={() => setFilterType(type)}
className="relative"
>
{label}
{count !== undefined && (
<Badge variant={filterType === type ? 'secondary' : 'default'} className="ml-2">
{count}
</Badge>
)}
</Button>
))}
</div>
</Card>
{/* Custom Model Input */}
<CustomModelInput
isDownloading={isDownloading}
onDownload={handleCustomDownload}
/>
{/* Model List */}
{isLoading ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground">{formatMessage({ id: 'common.actions.loading' })}</p>
</Card>
) : filteredModels.length === 0 ? (
<Card className="p-8 text-center">
<Package className="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'codexlens.models.empty.title' })}
</h3>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.models.empty.description' })}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredModels.map((model) => (
<ModelCard
key={model.profile}
model={model}
isDownloading={downloadingProfile === model.profile}
downloadProgress={downloadProgress}
isDeleting={isDeleting && downloadingProfile !== model.profile}
onDownload={handleDownload}
onDelete={handleDelete}
onCancelDownload={() => {
setDownloadingProfile(null);
setDownloadProgress(0);
}}
/>
))}
</div>
)}
</div>
);
}
export default ModelsTab;

View File

@@ -0,0 +1,280 @@
// ========================================
// Overview Tab Component Tests
// ========================================
// Tests for CodexLens Overview Tab component
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { OverviewTab } from './OverviewTab';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
const mockStatus: CodexLensVenvStatus = {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
};
const mockConfig: CodexLensConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
api_max_workers: 4,
api_batch_size: 8,
};
// Mock window.alert
global.alert = vi.fn();
describe('OverviewTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when installed and ready', () => {
const defaultProps = {
installed: true,
status: mockStatus,
config: mockConfig,
isLoading: false,
};
it('should render status cards', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
expect(screen.getByText(/Ready/i)).toBeInTheDocument();
expect(screen.getByText(/Version/i)).toBeInTheDocument();
expect(screen.getByText(/1.0.0/i)).toBeInTheDocument();
});
it('should render index path with full path in title', () => {
render(<OverviewTab {...defaultProps} />);
const indexPath = screen.getByText(/Index Path/i).nextElementSibling as HTMLElement;
expect(indexPath).toHaveTextContent('~/.codexlens/indexes');
expect(indexPath).toHaveAttribute('title', '~/.codexlens/indexes');
});
it('should render index count', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Index Count/i)).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
it('should render quick actions section', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Quick Actions/i)).toBeInTheDocument();
expect(screen.getByText(/FTS Full/i)).toBeInTheDocument();
expect(screen.getByText(/FTS Incremental/i)).toBeInTheDocument();
expect(screen.getByText(/Vector Full/i)).toBeInTheDocument();
expect(screen.getByText(/Vector Incremental/i)).toBeInTheDocument();
});
it('should render venv details section', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Python Virtual Environment Details/i)).toBeInTheDocument();
expect(screen.getByText(/Python Version/i)).toBeInTheDocument();
expect(screen.getByText(/3.11.0/i)).toBeInTheDocument();
});
it('should show coming soon alert when action clicked', async () => {
const user = userEvent.setup();
render(<OverviewTab {...defaultProps} />);
const ftsFullButton = screen.getByText(/FTS Full/i);
await user.click(ftsFullButton);
expect(global.alert).toHaveBeenCalledWith(expect.stringContaining('Coming Soon'));
});
});
describe('when installed but not ready', () => {
const notReadyProps = {
installed: true,
status: { ...mockStatus, ready: false },
config: mockConfig,
isLoading: false,
};
it('should show not ready status', () => {
render(<OverviewTab {...notReadyProps} />);
expect(screen.getByText(/Not Ready/i)).toBeInTheDocument();
});
it('should disable action buttons when not ready', () => {
render(<OverviewTab {...notReadyProps} />);
const ftsFullButton = screen.getByText(/FTS Full/i).closest('button');
expect(ftsFullButton).toBeDisabled();
});
});
describe('when not installed', () => {
const notInstalledProps = {
installed: false,
status: undefined,
config: undefined,
isLoading: false,
};
it('should show not installed message', () => {
render(<OverviewTab {...notInstalledProps} />);
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
expect(screen.getByText(/Please install CodexLens to use semantic code search features/i)).toBeInTheDocument();
});
});
describe('loading state', () => {
it('should show loading skeleton', () => {
const { container } = render(
<OverviewTab
installed={false}
status={undefined}
config={undefined}
isLoading={true}
/>
);
// Check for pulse/skeleton elements
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
});
describe('i18n - Chinese locale', () => {
const defaultProps = {
installed: true,
status: mockStatus,
config: mockConfig,
isLoading: false,
};
it('should display translated text in Chinese', () => {
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
expect(screen.getByText(/安装状态/i)).toBeInTheDocument();
expect(screen.getByText(/就绪/i)).toBeInTheDocument();
expect(screen.getByText(/版本/i)).toBeInTheDocument();
expect(screen.getByText(/索引路径/i)).toBeInTheDocument();
expect(screen.getByText(/索引数量/i)).toBeInTheDocument();
expect(screen.getByText(/快速操作/i)).toBeInTheDocument();
expect(screen.getByText(/Python 虚拟环境详情/i)).toBeInTheDocument();
});
it('should translate action buttons', () => {
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
expect(screen.getByText(/FTS 全量/i)).toBeInTheDocument();
expect(screen.getByText(/FTS 增量/i)).toBeInTheDocument();
expect(screen.getByText(/向量全量/i)).toBeInTheDocument();
expect(screen.getByText(/向量增量/i)).toBeInTheDocument();
});
});
describe('status card colors', () => {
it('should show success color when ready', () => {
const { container } = render(
<OverviewTab
installed={true}
status={mockStatus}
config={mockConfig}
isLoading={false}
/>
);
// Check for success/ready indication (check icon or success color)
const statusCard = container.querySelector('.bg-success\\/10');
expect(statusCard).toBeInTheDocument();
});
it('should show warning color when not ready', () => {
const { container } = render(
<OverviewTab
installed={true}
status={{ ...mockStatus, ready: false }}
config={mockConfig}
isLoading={false}
/>
);
// Check for warning/not ready indication
const statusCard = container.querySelector('.bg-warning\\/10');
expect(statusCard).toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle missing status gracefully', () => {
render(
<OverviewTab
installed={true}
status={undefined}
config={mockConfig}
isLoading={false}
/>
);
// Should not crash and render available data
expect(screen.getByText(/Version/i)).toBeInTheDocument();
});
it('should handle missing config gracefully', () => {
render(
<OverviewTab
installed={true}
status={mockStatus}
config={undefined}
isLoading={false}
/>
);
// Should not crash and render available data
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
});
it('should handle empty index path', () => {
const emptyConfig: CodexLensConfig = {
index_dir: '',
index_count: 0,
api_max_workers: 4,
api_batch_size: 8,
};
render(
<OverviewTab
installed={true}
status={mockStatus}
config={emptyConfig}
isLoading={false}
/>
);
expect(screen.getByText(/Index Path/i)).toBeInTheDocument();
});
it('should handle unknown version', () => {
const unknownVersionStatus: CodexLensVenvStatus = {
...mockStatus,
version: '',
};
render(
<OverviewTab
installed={true}
status={unknownVersionStatus}
config={mockConfig}
isLoading={false}
/>
);
expect(screen.getByText(/Version/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,246 @@
// ========================================
// CodexLens Overview Tab
// ========================================
// Overview status display and quick actions for CodexLens
import { useIntl } from 'react-intl';
import {
Database,
FileText,
CheckCircle2,
XCircle,
RotateCw,
Zap,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
interface OverviewTabProps {
installed: boolean;
status?: CodexLensVenvStatus;
config?: CodexLensConfig;
isLoading: boolean;
}
export function OverviewTab({ installed, status, config, isLoading }: OverviewTabProps) {
const { formatMessage } = useIntl();
if (isLoading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="p-4">
<div className="animate-pulse">
<div className="h-4 bg-muted rounded w-20 mb-2" />
<div className="h-8 bg-muted rounded w-16" />
</div>
</Card>
))}
</div>
</div>
);
}
if (!installed) {
return (
<Card className="p-8 text-center">
<Database className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'codexlens.overview.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.notInstalled.message' })}
</p>
</Card>
);
}
const isReady = status?.ready ?? false;
const version = status?.version ?? 'Unknown';
const indexDir = config?.index_dir ?? '~/.codexlens/indexes';
const indexCount = config?.index_count ?? 0;
return (
<div className="space-y-6">
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Installation Status */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
isReady ? 'bg-success/10' : 'bg-warning/10'
)}>
{isReady ? (
<CheckCircle2 className="w-5 h-5 text-success" />
) : (
<XCircle className="w-5 h-5 text-warning" />
)}
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.installation' })}
</p>
<p className="text-lg font-semibold text-foreground">
{isReady
? formatMessage({ id: 'codexlens.overview.status.ready' })
: formatMessage({ id: 'codexlens.overview.status.notReady' })
}
</p>
</div>
</div>
</Card>
{/* Version */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Database className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.version' })}
</p>
<p className="text-lg font-semibold text-foreground">{version}</p>
</div>
</div>
</Card>
{/* Index Path */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-info/10">
<FileText className="w-5 h-5 text-info" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.indexPath' })}
</p>
<p className="text-sm font-semibold text-foreground truncate" title={indexDir}>
{indexDir}
</p>
</div>
</div>
</Card>
{/* Index Count */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent/10">
<Zap className="w-5 h-5 text-accent" />
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.indexCount' })}
</p>
<p className="text-lg font-semibold text-foreground">{indexCount}</p>
</div>
</div>
</Card>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.actions.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<QuickActionButton
icon={<RotateCw className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.ftsFull' })}
description={formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' })}
disabled={!isReady}
/>
<QuickActionButton
icon={<Zap className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' })}
description={formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' })}
disabled={!isReady}
/>
<QuickActionButton
icon={<RotateCw className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.vectorFull' })}
description={formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' })}
disabled={!isReady}
/>
<QuickActionButton
icon={<Zap className="w-4 h-4" />}
label={formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' })}
description={formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' })}
disabled={!isReady}
/>
</div>
</CardContent>
</Card>
{/* Venv Details */}
{status && (
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.venv.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.venv.pythonVersion' })}
</span>
<span className="text-foreground font-mono">{status.pythonVersion || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.venv.venvPath' })}
</span>
<span className="text-foreground font-mono truncate ml-4" title={status.venvPath}>
{status.venvPath || 'Unknown'}
</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
interface QuickActionButtonProps {
icon: React.ReactNode;
label: string;
description: string;
disabled?: boolean;
}
function QuickActionButton({ icon, label, description, disabled }: QuickActionButtonProps) {
const { formatMessage } = useIntl();
const handleClick = () => {
// TODO: Implement index operations in future tasks
// For now, show a message that this feature is coming soon
alert(formatMessage({ id: 'codexlens.comingSoon' }));
};
return (
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
onClick={handleClick}
disabled={disabled}
>
<div className="flex items-center gap-2 w-full">
<span className={cn('text-muted-foreground', disabled && 'opacity-50')}>
{icon}
</span>
<span className="font-medium">{label}</span>
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</Button>
);
}

View File

@@ -0,0 +1,456 @@
// ========================================
// Settings Tab Component Tests
// ========================================
// Tests for CodexLens Settings Tab component with form validation
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { SettingsTab } from './SettingsTab';
import type { CodexLensConfig } from '@/lib/api';
// Mock hooks - use importOriginal to preserve all exports
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensConfig: vi.fn(),
useUpdateCodexLensConfig: vi.fn(),
useNotifications: vi.fn(() => ({
success: vi.fn(),
error: vi.fn(),
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
addToast: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
connectWebSocket: vi.fn(),
disconnectWebSocket: vi.fn(),
})),
};
});
import { useCodexLensConfig, useUpdateCodexLensConfig, useNotifications } from '@/hooks';
const mockConfig: CodexLensConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
api_max_workers: 4,
api_batch_size: 8,
};
describe('SettingsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when enabled and config loaded', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
isUpdating: false,
error: null,
});
});
it('should render current info card', () => {
render(<SettingsTab enabled={true} />);
expect(screen.getByText(/Current Index Count/i)).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText(/Current Workers/i)).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText(/Current Batch Size/i)).toBeInTheDocument();
expect(screen.getByText('8')).toBeInTheDocument();
});
it('should render configuration form', () => {
render(<SettingsTab enabled={true} />);
expect(screen.getByText(/Basic Configuration/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Index Directory/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Max Workers/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Batch Size/i)).toBeInTheDocument();
});
it('should initialize form with config values', () => {
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
const maxWorkersInput = screen.getByLabelText(/Max Workers/i) as HTMLInputElement;
const batchSizeInput = screen.getByLabelText(/Batch Size/i) as HTMLInputElement;
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
expect(maxWorkersInput.value).toBe('4');
expect(batchSizeInput.value).toBe('8');
});
it('should show save button enabled when changes are made', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
const saveButton = screen.getByText(/Save/i);
expect(saveButton).toBeEnabled();
});
it('should disable save and reset buttons when no changes', () => {
render(<SettingsTab enabled={true} />);
const saveButton = screen.getByText(/Save/i);
const resetButton = screen.getByText(/Reset/i);
expect(saveButton).toBeDisabled();
expect(resetButton).toBeDisabled();
});
it('should call updateConfig on save', async () => {
const updateConfig = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig,
isUpdating: false,
error: null,
});
const success = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
success,
error: vi.fn(),
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
await waitFor(() => {
expect(updateConfig).toHaveBeenCalledWith({
index_dir: '/new/index/path',
api_max_workers: 4,
api_batch_size: 8,
});
});
});
it('should reset form on reset button click', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
expect(indexDirInput.value).toBe('/new/index/path');
const resetButton = screen.getByText(/Reset/i);
await user.click(resetButton);
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
});
});
describe('form validation', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
});
it('should validate index dir is required', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
});
it('should validate max workers range (1-32)', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const maxWorkersInput = screen.getByLabelText(/Max Workers/i);
await user.clear(maxWorkersInput);
await user.type(maxWorkersInput, '0');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Workers must be between 1 and 32/i)).toBeInTheDocument();
});
it('should validate max workers upper bound', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const maxWorkersInput = screen.getByLabelText(/Max Workers/i);
await user.clear(maxWorkersInput);
await user.type(maxWorkersInput, '33');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Workers must be between 1 and 32/i)).toBeInTheDocument();
});
it('should validate batch size range (1-64)', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const batchSizeInput = screen.getByLabelText(/Batch Size/i);
await user.clear(batchSizeInput);
await user.type(batchSizeInput, '0');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Batch size must be between 1 and 64/i)).toBeInTheDocument();
});
it('should validate batch size upper bound', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const batchSizeInput = screen.getByLabelText(/Batch Size/i);
await user.clear(batchSizeInput);
await user.type(batchSizeInput, '65');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Batch size must be between 1 and 64/i)).toBeInTheDocument();
});
it('should clear error when user fixes invalid input', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
await user.type(indexDirInput, '/valid/path');
expect(screen.queryByText(/Index directory is required/i)).not.toBeInTheDocument();
});
});
describe('when disabled', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
});
it('should not render when enabled is false', () => {
render(<SettingsTab enabled={false} />);
// When not enabled, the component may render nothing or an empty state
// This test documents the expected behavior
expect(screen.queryByText(/Basic Configuration/i)).not.toBeInTheDocument();
});
});
describe('loading states', () => {
it('should disable inputs when loading config', () => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
expect(indexDirInput).toBeDisabled();
});
it('should show saving state when updating', async () => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: true,
error: null,
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/path');
const saveButton = screen.getByText(/Saving/i);
expect(saveButton).toBeInTheDocument();
});
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
});
it('should display translated labels', () => {
render(<SettingsTab enabled={true} />, { locale: 'zh' });
expect(screen.getByText(/当前索引数量/i)).toBeInTheDocument();
expect(screen.getByText(/当前工作线程/i)).toBeInTheDocument();
expect(screen.getByText(/当前批次大小/i)).toBeInTheDocument();
expect(screen.getByText(/基本配置/i)).toBeInTheDocument();
expect(screen.getByText(/索引目录/i)).toBeInTheDocument();
expect(screen.getByText(/最大工作线程/i)).toBeInTheDocument();
expect(screen.getByText(/批次大小/i)).toBeInTheDocument();
expect(screen.getByText(/保存/i)).toBeInTheDocument();
expect(screen.getByText(/重置/i)).toBeInTheDocument();
});
it('should display translated validation errors', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />, { locale: 'zh' });
const indexDirInput = screen.getByLabelText(/索引目录/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/保存/i);
await user.click(saveButton);
expect(screen.getByText(/索引目录不能为空/i)).toBeInTheDocument();
});
});
describe('error handling', () => {
it('should show error notification on save failure', async () => {
const error = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
success: vi.fn(),
error,
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
addToast: vi.fn(),
removeToast: vi.fn(),
clearToasts: vi.fn(),
connectWebSocket: vi.fn(),
disconnectWebSocket: vi.fn(),
});
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: false, message: 'Save failed' }),
isUpdating: false,
error: null,
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/path');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
await waitFor(() => {
expect(error).toHaveBeenCalledWith(
expect.stringContaining('Save failed'),
expect.any(String)
);
});
});
});
});

View File

@@ -0,0 +1,272 @@
// ========================================
// CodexLens Settings Tab
// ========================================
// Configuration form for basic CodexLens settings
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { useCodexLensConfig, useUpdateCodexLensConfig } from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
interface SettingsTabProps {
enabled?: boolean;
}
interface FormErrors {
index_dir?: string;
api_max_workers?: string;
api_batch_size?: string;
}
export function SettingsTab({ enabled = true }: SettingsTabProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const {
config,
indexCount,
apiMaxWorkers,
apiBatchSize,
isLoading: isLoadingConfig,
refetch,
} = useCodexLensConfig({ enabled });
const { updateConfig, isUpdating } = useUpdateCodexLensConfig();
// Form state
const [formData, setFormData] = useState({
index_dir: '',
api_max_workers: 4,
api_batch_size: 8,
});
const [errors, setErrors] = useState<FormErrors>({});
const [hasChanges, setHasChanges] = useState(false);
// Initialize form from config
useEffect(() => {
if (config) {
setFormData({
index_dir: config.index_dir || '',
api_max_workers: config.api_max_workers || 4,
api_batch_size: config.api_batch_size || 8,
});
setErrors({});
setHasChanges(false);
}
}, [config]);
const handleFieldChange = (field: keyof typeof formData, value: string | number) => {
setFormData((prev) => {
const newData = { ...prev, [field]: value };
// Check if there are changes
if (config) {
const changed =
newData.index_dir !== config.index_dir ||
newData.api_max_workers !== config.api_max_workers ||
newData.api_batch_size !== config.api_batch_size;
setHasChanges(changed);
}
return newData;
});
// Clear error for this field
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// Index dir required
if (!formData.index_dir.trim()) {
newErrors.index_dir = formatMessage({ id: 'codexlens.settings.validation.indexDirRequired' });
}
// API max workers: 1-32
if (formData.api_max_workers < 1 || formData.api_max_workers > 32) {
newErrors.api_max_workers = formatMessage({ id: 'codexlens.settings.validation.maxWorkersRange' });
}
// API batch size: 1-64
if (formData.api_batch_size < 1 || formData.api_batch_size > 64) {
newErrors.api_batch_size = formatMessage({ id: 'codexlens.settings.validation.batchSizeRange' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validateForm()) {
return;
}
try {
const result = await updateConfig({
index_dir: formData.index_dir,
api_max_workers: formData.api_max_workers,
api_batch_size: formData.api_batch_size,
});
if (result.success) {
success(
formatMessage({ id: 'codexlens.settings.saveSuccess' }),
result.message || formatMessage({ id: 'codexlens.settings.configUpdated' })
);
refetch();
} else {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
result.message || formatMessage({ id: 'codexlens.settings.saveError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.settings.unknownError' })
);
}
};
const handleReset = () => {
if (config) {
setFormData({
index_dir: config.index_dir || '',
api_max_workers: config.api_max_workers || 4,
api_batch_size: config.api_batch_size || 8,
});
setErrors({});
setHasChanges(false);
}
};
const isLoading = isLoadingConfig;
return (
<div className="space-y-6">
{/* Current Info Card */}
<Card className="p-4 bg-muted/30">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentCount' })}</span>
<p className="text-foreground font-medium">{indexCount}</p>
</div>
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentWorkers' })}</span>
<p className="text-foreground font-medium">{apiMaxWorkers}</p>
</div>
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentBatchSize' })}</span>
<p className="text-foreground font-medium">{apiBatchSize}</p>
</div>
</div>
</Card>
{/* Configuration Form */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'codexlens.settings.configTitle' })}
</h3>
<div className="space-y-4">
{/* Index Directory */}
<div className="space-y-2">
<Label htmlFor="index_dir">
{formatMessage({ id: 'codexlens.settings.indexDir.label' })}
</Label>
<Input
id="index_dir"
value={formData.index_dir}
onChange={(e) => handleFieldChange('index_dir', e.target.value)}
placeholder={formatMessage({ id: 'codexlens.settings.indexDir.placeholder' })}
error={!!errors.index_dir}
disabled={isLoading}
/>
{errors.index_dir && (
<p className="text-sm text-destructive">{errors.index_dir}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.indexDir.hint' })}
</p>
</div>
{/* API Max Workers */}
<div className="space-y-2">
<Label htmlFor="api_max_workers">
{formatMessage({ id: 'codexlens.settings.maxWorkers.label' })}
</Label>
<Input
id="api_max_workers"
type="number"
min="1"
max="32"
value={formData.api_max_workers}
onChange={(e) => handleFieldChange('api_max_workers', parseInt(e.target.value) || 1)}
error={!!errors.api_max_workers}
disabled={isLoading}
/>
{errors.api_max_workers && (
<p className="text-sm text-destructive">{errors.api_max_workers}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.maxWorkers.hint' })}
</p>
</div>
{/* API Batch Size */}
<div className="space-y-2">
<Label htmlFor="api_batch_size">
{formatMessage({ id: 'codexlens.settings.batchSize.label' })}
</Label>
<Input
id="api_batch_size"
type="number"
min="1"
max="64"
value={formData.api_batch_size}
onChange={(e) => handleFieldChange('api_batch_size', parseInt(e.target.value) || 1)}
error={!!errors.api_batch_size}
disabled={isLoading}
/>
{errors.api_batch_size && (
<p className="text-sm text-destructive">{errors.api_batch_size}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.batchSize.hint' })}
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 mt-6">
<Button
onClick={handleSave}
disabled={isLoading || isUpdating || !hasChanges}
>
<Save className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')} />
{isUpdating
? formatMessage({ id: 'codexlens.settings.saving' })
: formatMessage({ id: 'codexlens.settings.save' })
}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || !hasChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.settings.reset' })}
</Button>
</div>
</Card>
</div>
);
}
export default SettingsTab;

View File

@@ -56,6 +56,19 @@ export interface HookQuickTemplatesProps {
* Predefined hook templates for quick installation * Predefined hook templates for quick installation
*/ */
export const HOOK_TEMPLATES: readonly HookTemplate[] = [ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
{
id: 'ccw-status-tracker',
name: 'CCW Status Tracker',
description: 'Parse CCW status.json and display current/next command',
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" ] && [[ "$FILE_PATH" == *"status.json" ]] && ccw hook parse-status --path "$FILE_PATH" || true'
]
},
{ {
id: 'ccw-notify', id: 'ccw-notify',
name: 'CCW Dashboard Notify', name: 'CCW Dashboard Notify',

View File

@@ -5,13 +5,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { Download, FileText, BarChart3, Info } from 'lucide-react'; import { Download, FileText, BarChart3, Info, Upload } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress'; import { Progress } from '@/components/ui/Progress';
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import type { DiscoverySession, Finding } from '@/lib/api'; import type { DiscoverySession, Finding } from '@/lib/api';
import type { Issue } from '@/lib/api';
import type { FindingFilters } from '@/hooks/useIssues'; import type { FindingFilters } from '@/hooks/useIssues';
import { FindingList } from './FindingList'; import { FindingList } from './FindingList';
@@ -22,6 +24,9 @@ interface DiscoveryDetailProps {
filters: FindingFilters; filters: FindingFilters;
onFilterChange: (filters: FindingFilters) => void; onFilterChange: (filters: FindingFilters) => void;
onExport: () => void; onExport: () => void;
onExportSelected?: (findingIds: string[]) => Promise<{ success: boolean; message?: string; exported?: number }>;
isExporting?: boolean;
issues?: Issue[]; // Optional: pass issues to find related ones
} }
export function DiscoveryDetail({ export function DiscoveryDetail({
@@ -31,9 +36,35 @@ export function DiscoveryDetail({
filters, filters,
onFilterChange, onFilterChange,
onExport, onExport,
onExportSelected,
isExporting = false,
issues = [],
}: DiscoveryDetailProps) { }: DiscoveryDetailProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState('findings'); const [activeTab, setActiveTab] = useState('findings');
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const handleFindingClick = (finding: Finding) => {
// If finding has an associated issue_id, find and show that issue
if (finding.issue_id) {
const relatedIssue = issues.find(i => i.id === finding.issue_id);
if (relatedIssue) {
setSelectedIssue(relatedIssue);
}
}
};
const handleCloseDrawer = () => {
setSelectedIssue(null);
};
const handleExportSelected = async () => {
if (onExportSelected && selectedIds.length > 0) {
await onExportSelected(selectedIds);
setSelectedIds([]); // Clear selection after export
}
};
if (!session) { if (!session) {
return ( return (
@@ -73,10 +104,25 @@ export function DiscoveryDetail({
{formatMessage({ id: 'issues.discovery.sessionId' })}: {session.id} {formatMessage({ id: 'issues.discovery.sessionId' })}: {session.id}
</p> </p>
</div> </div>
<Button variant="outline" onClick={onExport} disabled={findings.length === 0}> <div className="flex items-center gap-2">
<Download className="w-4 h-4 mr-2" /> {selectedIds.length > 0 && onExportSelected && (
{formatMessage({ id: 'issues.discovery.export' })} <Button
</Button> variant="default"
onClick={handleExportSelected}
disabled={isExporting || selectedIds.length === 0}
>
<Upload className="w-4 h-4 mr-2" />
{isExporting
? formatMessage({ id: 'issues.discovery.exporting' })
: formatMessage({ id: 'issues.discovery.exportSelected' }, { count: selectedIds.length })
}
</Button>
)}
<Button variant="outline" onClick={onExport} disabled={findings.length === 0}>
<Download className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.discovery.export' })}
</Button>
</div>
</div> </div>
{/* Status Badge */} {/* Status Badge */}
@@ -125,7 +171,14 @@ export function DiscoveryDetail({
</TabsList> </TabsList>
<TabsContent value="findings" className="mt-4"> <TabsContent value="findings" className="mt-4">
<FindingList findings={findings} filters={filters} onFilterChange={onFilterChange} /> <FindingList
findings={findings}
filters={filters}
onFilterChange={onFilterChange}
onFindingClick={handleFindingClick}
selectedIds={selectedIds}
onSelectionChange={onExportSelected ? setSelectedIds : undefined}
/>
</TabsContent> </TabsContent>
<TabsContent value="progress" className="mt-4 space-y-4"> <TabsContent value="progress" className="mt-4 space-y-4">
@@ -219,6 +272,15 @@ export function DiscoveryDetail({
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Issue Detail Drawer */}
<IssueDrawer
issue={selectedIssue}
isOpen={selectedIssue !== null}
onClose={handleCloseDrawer}
/>
</div> </div>
); );
} }
export default DiscoveryDetail;

View File

@@ -3,12 +3,14 @@
// ======================================== // ========================================
// Displays findings with filters and severity badges // Displays findings with filters and severity badges
import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { Search, FileCode, AlertTriangle } from 'lucide-react'; import { Search, FileCode, AlertTriangle, ExternalLink, Check } from 'lucide-react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
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 { cn } from '@/lib/utils';
import type { Finding } from '@/lib/api'; import type { Finding } from '@/lib/api';
import type { FindingFilters } from '@/hooks/useIssues'; import type { FindingFilters } from '@/hooks/useIssues';
@@ -16,17 +18,64 @@ interface FindingListProps {
findings: Finding[]; findings: Finding[];
filters: FindingFilters; filters: FindingFilters;
onFilterChange: (filters: FindingFilters) => void; onFilterChange: (filters: FindingFilters) => void;
onFindingClick?: (finding: Finding) => void;
selectedIds?: string[];
onSelectionChange?: (selectedIds: string[]) => void;
} }
const severityConfig = { const severityConfig: Record<string, { variant: 'destructive' | 'warning' | 'secondary' | 'outline' | 'success' | 'info' | 'default'; label: string }> = {
critical: { variant: 'destructive' as const, label: 'issues.discovery.severity.critical' }, critical: { variant: 'destructive', label: 'issues.discovery.severity.critical' },
high: { variant: 'destructive' as const, label: 'issues.discovery.severity.high' }, high: { variant: 'destructive', label: 'issues.discovery.severity.high' },
medium: { variant: 'warning' as const, label: 'issues.discovery.severity.medium' }, medium: { variant: 'warning', label: 'issues.discovery.severity.medium' },
low: { variant: 'secondary' as const, label: 'issues.discovery.severity.low' }, low: { variant: 'secondary', label: 'issues.discovery.severity.low' },
}; };
export function FindingList({ findings, filters, onFilterChange }: FindingListProps) { function getSeverityConfig(severity: string) {
return severityConfig[severity] || { variant: 'outline', label: 'issues.discovery.severity.unknown' };
}
export function FindingList({
findings,
filters,
onFilterChange,
onFindingClick,
selectedIds = [],
onSelectionChange,
}: FindingListProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [internalSelection, setInternalSelection] = useState<Set<string>>(new Set());
// Use external selection if provided, otherwise use internal state
const selectionSet = onSelectionChange
? new Set(selectedIds)
: internalSelection;
const handleToggleSelection = (findingId: string) => {
const newSet = new Set(selectionSet);
if (newSet.has(findingId)) {
newSet.delete(findingId);
} else {
newSet.add(findingId);
}
if (onSelectionChange) {
onSelectionChange(Array.from(newSet));
} else {
setInternalSelection(newSet);
}
};
const handleToggleAll = () => {
const allSelected = selectionSet.size === findings.length && findings.length > 0;
const newSet = allSelected ? new Set<string>() : new Set(findings.map(f => f.id));
if (onSelectionChange) {
onSelectionChange(Array.from(newSet));
} else {
setInternalSelection(newSet);
}
};
const isAllSelected = findings.length > 0 && selectionSet.size === findings.length;
const isSomeSelected = selectionSet.size > 0 && selectionSet.size < findings.length;
// Extract unique types for filter // Extract unique types for filter
const uniqueTypes = Array.from(new Set(findings.map(f => f.type))).sort(); const uniqueTypes = Array.from(new Set(findings.map(f => f.type))).sort();
@@ -36,10 +85,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<AlertTriangle className="w-12 h-12 mx-auto text-muted-foreground/50" /> <AlertTriangle className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground"> <h3 className="mt-4 text-lg font-medium text-foreground">
{formatMessage({ id: 'issues.discovery.noFindings' })} {formatMessage({ id: 'issues.discovery.findings.noFindings' })}
</h3> </h3>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
{formatMessage({ id: 'issues.discovery.noFindingsDescription' })} {formatMessage({ id: 'issues.discovery.findings.noFindingsDescription' })}
</p> </p>
</Card> </Card>
); );
@@ -52,7 +101,7 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
<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" />
<Input <Input
placeholder={formatMessage({ id: 'issues.discovery.searchPlaceholder' })} placeholder={formatMessage({ id: 'issues.discovery.findings.searchPlaceholder' })}
value={filters.search || ''} value={filters.search || ''}
onChange={(e) => onFilterChange({ ...filters, search: e.target.value || undefined })} onChange={(e) => onFilterChange({ ...filters, search: e.target.value || undefined })}
className="pl-9" className="pl-9"
@@ -63,10 +112,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
onValueChange={(v) => onFilterChange({ ...filters, severity: v === 'all' ? undefined : v as Finding['severity'] })} onValueChange={(v) => onFilterChange({ ...filters, severity: v === 'all' ? undefined : v as Finding['severity'] })}
> >
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[140px]">
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.filterBySeverity' })} /> <SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterBySeverity' })} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.allSeverities' })}</SelectItem> <SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.severity.all' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'issues.discovery.severity.critical' })}</SelectItem> <SelectItem value="critical">{formatMessage({ id: 'issues.discovery.severity.critical' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'issues.discovery.severity.high' })}</SelectItem> <SelectItem value="high">{formatMessage({ id: 'issues.discovery.severity.high' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'issues.discovery.severity.medium' })}</SelectItem> <SelectItem value="medium">{formatMessage({ id: 'issues.discovery.severity.medium' })}</SelectItem>
@@ -79,50 +128,155 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
onValueChange={(v) => onFilterChange({ ...filters, type: v === 'all' ? undefined : v })} onValueChange={(v) => onFilterChange({ ...filters, type: v === 'all' ? undefined : v })}
> >
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[140px]">
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.filterByType' })} /> <SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterByType' })} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.allTypes' })}</SelectItem> <SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.type.all' })}</SelectItem>
{uniqueTypes.map(type => ( {uniqueTypes.map(type => (
<SelectItem key={type} value={type}>{type}</SelectItem> <SelectItem key={type} value={type}>{type}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
<Select
value={filters.exported === undefined ? 'all' : filters.exported ? 'exported' : 'notExported'}
onValueChange={(v) => {
if (v === 'all') {
onFilterChange({ ...filters, exported: undefined });
} else if (v === 'exported') {
onFilterChange({ ...filters, exported: true });
} else {
onFilterChange({ ...filters, exported: false });
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterByExported' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.exportedStatus.all' })}</SelectItem>
<SelectItem value="exported">{formatMessage({ id: 'issues.discovery.findings.exportedStatus.exported' })}</SelectItem>
<SelectItem value="notExported">{formatMessage({ id: 'issues.discovery.findings.exportedStatus.notExported' })}</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.hasIssue === undefined ? 'all' : filters.hasIssue ? 'hasIssue' : 'noIssue'}
onValueChange={(v) => {
if (v === 'all') {
onFilterChange({ ...filters, hasIssue: undefined });
} else if (v === 'hasIssue') {
onFilterChange({ ...filters, hasIssue: true });
} else {
onFilterChange({ ...filters, hasIssue: false });
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterByIssue' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.issueStatus.all' })}</SelectItem>
<SelectItem value="hasIssue">{formatMessage({ id: 'issues.discovery.findings.issueStatus.hasIssue' })}</SelectItem>
<SelectItem value="noIssue">{formatMessage({ id: 'issues.discovery.findings.issueStatus.noIssue' })}</SelectItem>
</SelectContent>
</Select>
</div> </div>
{/* Select All */}
{onSelectionChange && (
<button
onClick={handleToggleAll}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<div className="w-4 h-4 border rounded flex items-center justify-center">
{isAllSelected ? (
<Check className="w-3 h-3" />
) : isSomeSelected ? (
<div className="w-2 h-2 bg-foreground rounded-sm" />
) : null}
</div>
{isAllSelected
? formatMessage({ id: 'issues.discovery.findings.deselectAll' })
: formatMessage({ id: 'issues.discovery.findings.selectAll' })}
</button>
)}
{/* Findings List */} {/* Findings List */}
<div className="space-y-3"> <div className="space-y-3">
{findings.map((finding) => { {findings.map((finding) => {
const config = severityConfig[finding.severity]; const config = getSeverityConfig(finding.severity);
const isSelected = selectionSet.has(finding.id);
return ( return (
<Card key={finding.id} className="p-4"> <Card
<div className="flex items-start justify-between gap-3 mb-2"> key={finding.id}
<div className="flex items-center gap-2 flex-wrap"> className={cn(
<Badge variant={config.variant}> "p-4 transition-colors",
{formatMessage({ id: config.label })} onFindingClick && "cursor-pointer hover:bg-muted/50"
</Badge> )}
{finding.type && ( onClick={(e) => {
<Badge variant="outline" className="text-xs"> // Don't trigger finding click when clicking checkbox
{finding.type} if ((e.target as HTMLElement).closest('.selection-checkbox')) return;
</Badge> onFindingClick?.(finding);
)} }}
</div> >
{finding.file && ( <div className="flex items-start gap-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground"> {/* Checkbox */}
<FileCode className="w-3 h-3" /> {onSelectionChange && (
<span>{finding.file}</span> <div
{finding.line && <span>:{finding.line}</span>} className="selection-checkbox flex-shrink-0 mt-1"
onClick={(e) => {
e.stopPropagation();
handleToggleSelection(finding.id);
}}
>
<div className={cn(
"w-4 h-4 border rounded flex items-center justify-center cursor-pointer transition-colors",
isSelected ? "bg-primary border-primary" : "border-border hover:border-primary"
)}>
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
</div>
</div> </div>
)} )}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={config.variant}>
{formatMessage({ id: config.label })}
</Badge>
{finding.type && (
<Badge variant="outline" className="text-xs">
{finding.type}
</Badge>
)}
{finding.exported && (
<Badge variant="success" className="text-xs gap-1">
<ExternalLink className="w-3 h-3" />
{formatMessage({ id: 'issues.discovery.findings.exported' })}
</Badge>
)}
{finding.issue_id && (
<Badge variant="info" className="text-xs">
{formatMessage({ id: 'issues.discovery.findings.hasIssue' })}: {finding.issue_id}
</Badge>
)}
</div>
{finding.file && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FileCode className="w-3 h-3" />
<span>{finding.file}</span>
{finding.line && <span>:{finding.line}</span>}
</div>
)}
</div>
<h4 className="font-medium text-foreground mb-1">{finding.title}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{finding.description}</p>
{finding.code_snippet && (
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">
<code>{finding.code_snippet}</code>
</pre>
)}
</div>
</div> </div>
<h4 className="font-medium text-foreground mb-1">{finding.title}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{finding.description}</p>
{finding.code_snippet && (
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">
<code>{finding.code_snippet}</code>
</pre>
)}
</Card> </Card>
); );
})} })}
@@ -130,8 +284,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
{/* Count */} {/* Count */}
<div className="text-center text-sm text-muted-foreground"> <div className="text-center text-sm text-muted-foreground">
{formatMessage({ id: 'issues.discovery.showingCount' }, { count: findings.length })} {formatMessage({ id: 'issues.discovery.findings.showingCount' }, { count: findings.length })}
</div> </div>
</div> </div>
); );
} }
export default FindingList;

View File

@@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
import { Radar, AlertCircle, Loader2 } from 'lucide-react'; import { Radar, AlertCircle, Loader2 } from 'lucide-react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { useIssueDiscovery } from '@/hooks/useIssues'; import { useIssueDiscovery, useIssues } from '@/hooks/useIssues';
import { DiscoveryCard } from '@/components/issue/discovery/DiscoveryCard'; import { DiscoveryCard } from '@/components/issue/discovery/DiscoveryCard';
import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail'; import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail';
@@ -27,8 +27,16 @@ export function DiscoveryPanel() {
setFilters, setFilters,
selectSession, selectSession,
exportFindings, exportFindings,
exportSelectedFindings,
isExporting,
} = useIssueDiscovery({ refetchInterval: 3000 }); } = useIssueDiscovery({ refetchInterval: 3000 });
// Fetch issues to find related ones when clicking findings
const { issues } = useIssues({
// Don't apply filters to get all issues for matching
filter: undefined
});
if (error) { if (error) {
return ( return (
<Card className="p-8 text-center"> <Card className="p-8 text-center">
@@ -144,6 +152,9 @@ export function DiscoveryPanel() {
filters={filters} filters={filters}
onFilterChange={setFilters} onFilterChange={setFilters}
onExport={exportFindings} onExport={exportFindings}
onExportSelected={exportSelectedFindings}
isExporting={isExporting}
issues={issues}
/> />
)} )}
</div> </div>

View File

@@ -0,0 +1,238 @@
// ========================================
// IssueDrawer Component
// ========================================
// Right-side issue detail drawer with Overview/Solutions/History tabs
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { cn } from '@/lib/utils';
import type { Issue } from '@/lib/api';
// ========== Types ==========
export interface IssueDrawerProps {
issue: Issue | null;
isOpen: boolean;
onClose: () => void;
}
type TabValue = 'overview' | 'solutions' | 'history' | 'json';
// ========== Status Configuration ==========
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
open: { label: 'issues.status.open', variant: 'info', icon: Circle },
in_progress: { label: 'issues.status.inProgress', variant: 'warning', icon: Loader2 },
resolved: { label: 'issues.status.resolved', variant: 'success', icon: CheckCircle },
closed: { label: 'issues.status.closed', variant: 'secondary', icon: Circle },
completed: { label: 'issues.status.completed', variant: 'success', icon: CheckCircle },
};
const priorityConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' }> = {
low: { label: 'issues.priority.low', variant: 'secondary' },
medium: { label: 'issues.priority.medium', variant: 'default' },
high: { label: 'issues.priority.high', variant: 'warning' },
critical: { label: 'issues.priority.critical', variant: 'destructive' },
};
// ========== Component ==========
export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<TabValue>('overview');
// Reset to overview when issue changes
useState(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
});
if (!issue || !isOpen) {
return null;
}
const status = statusConfig[issue.status] || statusConfig.open;
const priority = priorityConfig[issue.priority] || priorityConfig.medium;
return (
<>
{/* Overlay */}
<div
className={cn(
'fixed inset-0 bg-black/40 transition-opacity z-40',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={cn(
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
role="dialog"
aria-modal="true"
style={{ minWidth: '400px', maxWidth: '800px' }}
>
{/* Header */}
<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 items-center gap-2 mb-2 flex-wrap">
<span className="text-xs font-mono text-muted-foreground">{issue.id}</span>
<Badge variant={status.variant} className="gap-1">
<status.icon className="h-3 w-3" />
{formatMessage({ id: status.label })}
</Badge>
<Badge variant={priority.variant}>
{formatMessage({ id: priority.label })}
</Badge>
</div>
<h2 className="text-lg font-semibold text-foreground">
{issue.title}
</h2>
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
<X className="h-5 w-5" />
</Button>
</div>
{/* Tabs */}
<div className="px-6 pt-4 bg-card">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)} className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview" className="flex-1">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.detail.tabs.overview' })}
</TabsTrigger>
<TabsTrigger value="solutions" className="flex-1">
<CheckCircle className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.detail.tabs.solutions' })}
{issue.solutions && issue.solutions.length > 0 && (
<Badge variant="secondary" className="ml-1">
{issue.solutions.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="history" className="flex-1">
<History className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.detail.tabs.history' })}
</TabsTrigger>
<TabsTrigger value="json" className="flex-1">
<Hash className="h-4 w-4 mr-2" />
JSON
</TabsTrigger>
</TabsList>
{/* Tab Content */}
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
{/* Overview Tab */}
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
<div className="space-y-6">
{/* Context */}
{issue.context && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'issues.detail.overview.context' })}
</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{issue.context}
</p>
</div>
)}
{/* Labels */}
{issue.labels && issue.labels.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'issues.detail.overview.labels' })}
</h3>
<div className="flex flex-wrap gap-2">
{issue.labels.map((label, index) => (
<Badge key={index} variant="outline" className="gap-1">
<Tag className="h-3 w-3" />
{label}
</Badge>
))}
</div>
</div>
)}
{/* Meta Info */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.detail.overview.createdAt' })}</p>
<p className="text-sm">{new Date(issue.createdAt).toLocaleString()}</p>
</div>
{issue.updatedAt && (
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.detail.overview.updatedAt' })}</p>
<p className="text-sm">{new Date(issue.updatedAt).toLocaleString()}</p>
</div>
)}
</div>
</div>
</TabsContent>
{/* Solutions Tab */}
<TabsContent value="solutions" className="mt-4 pb-6 focus-visible:outline-none">
{!issue.solutions || issue.solutions.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'issues.detail.solutions.empty' })}</p>
</div>
) : (
<div className="space-y-4">
{issue.solutions.map((solution, index) => (
<div key={solution.id || index} className="p-4 bg-muted/50 rounded-md border border-border">
<div className="flex items-center justify-between mb-2">
<Badge variant={solution.status === 'completed' ? 'success' : 'secondary'}>
{solution.status}
</Badge>
{solution.estimatedEffort && (
<span className="text-xs text-muted-foreground">
{solution.estimatedEffort}
</span>
)}
</div>
<p className="text-sm font-medium mb-1">{solution.description}</p>
{solution.approach && (
<p className="text-xs text-muted-foreground">{solution.approach}</p>
)}
</div>
))}
</div>
)}
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="mt-4 pb-6 focus-visible:outline-none">
<div className="text-center py-12 text-muted-foreground">
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'issues.detail.history.empty' })}</p>
</div>
</TabsContent>
{/* JSON Tab */}
<TabsContent value="json" className="mt-4 pb-6 focus-visible:outline-none">
<pre className="p-4 bg-muted rounded-md overflow-x-auto text-xs">
{JSON.stringify(issue, null, 2)}
</pre>
</TabsContent>
</div>
</Tabs>
</div>
</div>
</>
);
}
export default IssueDrawer;

View File

@@ -6,115 +6,27 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { import {
Plus,
Search, Search,
RefreshCw,
Loader2,
Github,
CheckCircle, CheckCircle,
Clock, Clock,
AlertTriangle, AlertTriangle,
AlertCircle, AlertCircle,
} 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 { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; import { Button } from '@/components/ui/Button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { IssueCard } from '@/components/shared/IssueCard'; import { IssueCard } from '@/components/shared/IssueCard';
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import { useIssues, useIssueMutations } from '@/hooks'; import { useIssues, useIssueMutations } from '@/hooks';
import type { Issue } from '@/lib/api'; import type { Issue } from '@/lib/api';
import { cn } from '@/lib/utils';
type StatusFilter = 'all' | Issue['status']; type StatusFilter = 'all' | Issue['status'];
type PriorityFilter = 'all' | Issue['priority']; type PriorityFilter = 'all' | Issue['priority'];
interface NewIssueDialogProps { interface IssuesPanelProps {
open: boolean; onCreateIssue?: () => void;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
isCreating: boolean;
}
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: NewIssueDialogProps) {
const { formatMessage } = useIntl();
const [title, setTitle] = useState('');
const [context, setContext] = useState('');
const [priority, setPriority] = useState<Issue['priority']>('medium');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
setTitle('');
setContext('');
setPriority('medium');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
className="mt-1"
required
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
</Button>
<Button type="submit" disabled={isCreating || !title.trim()}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
} }
interface IssueListProps { interface IssueListProps {
@@ -165,14 +77,14 @@ function IssueList({ issues, isLoading, onIssueClick, onIssueEdit, onIssueDelete
); );
} }
export function IssuesPanel() { export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all'); const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false); const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const { issues, issuesByStatus, openCount, criticalCount, isLoading, isFetching, refetch } = useIssues({ const { issues, issuesByStatus, openCount, criticalCount, isLoading } = useIssues({
filter: { filter: {
search: searchQuery || undefined, search: searchQuery || undefined,
status: statusFilter !== 'all' ? [statusFilter] : undefined, status: statusFilter !== 'all' ? [statusFilter] : undefined,
@@ -180,7 +92,7 @@ export function IssuesPanel() {
}, },
}); });
const { createIssue, updateIssue, deleteIssue, isCreating } = useIssueMutations(); const { updateIssue, deleteIssue } = useIssueMutations();
const statusCounts = useMemo(() => ({ const statusCounts = useMemo(() => ({
all: issues.length, all: issues.length,
@@ -191,11 +103,6 @@ export function IssuesPanel() {
completed: issuesByStatus.completed?.length || 0, completed: issuesByStatus.completed?.length || 0,
}), [issues, issuesByStatus]); }), [issues, issuesByStatus]);
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
await createIssue(data);
setIsNewIssueOpen(false);
};
const handleEditIssue = (_issue: Issue) => {}; const handleEditIssue = (_issue: Issue) => {};
const handleDeleteIssue = async (issue: Issue) => { const handleDeleteIssue = async (issue: Issue) => {
@@ -208,23 +115,16 @@ export function IssuesPanel() {
await updateIssue(issue.id, { status }); await updateIssue(issue.id, { status });
}; };
const handleIssueClick = (issue: Issue) => {
setSelectedIssue(issue);
};
const handleCloseDrawer = () => {
setSelectedIssue(null);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-2 justify-end">
<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 variant="outline">
<Github className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.github' })}
</Button>
<Button onClick={() => setIsNewIssueOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.create' })}
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -312,9 +212,21 @@ export function IssuesPanel() {
</Button> </Button>
</div> </div>
<IssueList issues={issues} isLoading={isLoading} onIssueClick={() => {}} onIssueEdit={handleEditIssue} onIssueDelete={handleDeleteIssue} onStatusChange={handleStatusChange} /> <IssueList
issues={issues}
isLoading={isLoading}
onIssueClick={handleIssueClick}
onIssueEdit={handleEditIssue}
onIssueDelete={handleDeleteIssue}
onStatusChange={handleStatusChange}
/>
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} /> {/* Issue Detail Drawer */}
<IssueDrawer
issue={selectedIssue}
isOpen={selectedIssue !== null}
onClose={handleCloseDrawer}
/>
</div> </div>
); );
} }

View File

@@ -3,9 +3,9 @@
// ======================================== // ========================================
// Content panel for Queue tab in IssueHub // Content panel for Queue tab in IssueHub
import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { import {
RefreshCw,
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
Clock, Clock,
@@ -13,11 +13,11 @@ import {
GitMerge, GitMerge,
} 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 { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { QueueCard } from '@/components/issue/queue/QueueCard'; import { QueueCard } from '@/components/issue/queue/QueueCard';
import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
import { useIssueQueue, useQueueMutations } from '@/hooks'; import { useIssueQueue, useQueueMutations } from '@/hooks';
import { cn } from '@/lib/utils'; import type { QueueItem } from '@/lib/api';
// ========== Loading Skeleton ========== // ========== Loading Skeleton ==========
@@ -70,17 +70,20 @@ function QueueEmptyState() {
export function QueuePanel() { export function QueuePanel() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
const { data: queueData, isLoading, isFetching, refetch, error } = useIssueQueue(); const { data: queueData, isLoading, error } = useIssueQueue();
const { const {
activateQueue, activateQueue,
deactivateQueue, deactivateQueue,
deleteQueue, deleteQueue,
mergeQueues, mergeQueues,
splitQueue,
isActivating, isActivating,
isDeactivating, isDeactivating,
isDeleting, isDeleting,
isMerging, isMerging,
isSplitting,
} = useQueueMutations(); } = useQueueMutations();
// Get queue data with proper type // Get queue data with proper type
@@ -123,6 +126,22 @@ export function QueuePanel() {
} }
}; };
const handleSplit = async (sourceQueueId: string, itemIds: string[]) => {
try {
await splitQueue(sourceQueueId, itemIds);
} catch (err) {
console.error('Failed to split queue:', err);
}
};
const handleItemClick = (item: QueueItem) => {
setSelectedItem(item);
};
const handleCloseDrawer = () => {
setSelectedItem(null);
};
if (isLoading) { if (isLoading) {
return <QueuePanelSkeleton />; return <QueuePanelSkeleton />;
} }
@@ -150,18 +169,6 @@ export function QueuePanel() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header Actions */}
<div className="flex justify-end">
<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>
</div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4"> <Card className="p-4">
@@ -229,13 +236,23 @@ export function QueuePanel() {
onDeactivate={handleDeactivate} onDeactivate={handleDeactivate}
onDelete={handleDelete} onDelete={handleDelete}
onMerge={handleMerge} onMerge={handleMerge}
onSplit={handleSplit}
onItemClick={handleItemClick}
isActivating={isActivating} isActivating={isActivating}
isDeactivating={isDeactivating} isDeactivating={isDeactivating}
isDeleting={isDeleting} isDeleting={isDeleting}
isMerging={isMerging} isMerging={isMerging}
isSplitting={isSplitting}
/> />
</div> </div>
{/* Solution Detail Drawer */}
<SolutionDrawer
item={selectedItem}
isOpen={selectedItem !== null}
onClose={handleCloseDrawer}
/>
{/* Status Footer */} {/* Status Footer */}
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg"> <div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">

View File

@@ -9,20 +9,22 @@ import { ChevronDown, ChevronRight, GitMerge, ArrowRight } from 'lucide-react';
import { Card, CardHeader } from '@/components/ui/Card'; import { Card, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { QueueItem } from '@/lib/api';
// ========== Types ========== // ========== Types ==========
export interface ExecutionGroupProps { export interface ExecutionGroupProps {
group: string; group: string;
items: string[]; items: QueueItem[];
type?: 'parallel' | 'sequential'; type?: 'parallel' | 'sequential';
onItemClick?: (item: QueueItem) => void;
} }
// ========== Component ========== // ========== Component ==========
export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionGroupProps) { export function ExecutionGroup({ group, items, type = 'sequential', onItemClick }: ExecutionGroupProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(false);
const isParallel = type === 'parallel'; const isParallel = type === 'parallel';
return ( return (
@@ -56,7 +58,7 @@ export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionG
</span> </span>
</div> </div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{items.length} {items.length === 1 ? 'item' : 'items'} {formatMessage({ id: 'issues.queue.itemCount' }, { count: items.length })}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
@@ -67,22 +69,38 @@ export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionG
"space-y-1 mt-2", "space-y-1 mt-2",
isParallel ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "space-y-1" isParallel ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "space-y-1"
)}> )}>
{items.map((item, index) => ( {items.map((item, index) => {
<div // Parse item_id to extract type and ID
key={item} const [itemType, ...idParts] = item.item_id.split('-');
className={cn( const displayId = idParts.join('-');
"flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm", const typeLabel = itemType === 'issue' ? formatMessage({ id: 'issues.solution.shortIssue' })
"hover:bg-muted transition-colors" : itemType === 'solution' ? formatMessage({ id: 'issues.solution.shortSolution' })
)} : itemType;
>
<span className="text-muted-foreground text-xs w-6"> return (
{isParallel ? '' : `${index + 1}.`} <div
</span> key={item.item_id}
<span className="font-mono text-xs truncate flex-1"> onClick={() => onItemClick?.(item)}
{item} className={cn(
</span> "flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm",
</div> "hover:bg-muted transition-colors cursor-pointer"
))} )}
>
<span className="text-muted-foreground text-xs w-6">
{isParallel ? '' : `${index + 1}.`}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{typeLabel}
</span>
<span className="font-mono text-xs truncate flex-1">
{displayId}
</span>
<Badge variant="outline" className="text-xs shrink-0">
{formatMessage({ id: `issues.queue.status.${item.status}` })}
</Badge>
</div>
);
})}
</div> </div>
</div> </div>
)} )}

View File

@@ -1,18 +1,11 @@
// ======================================== // ========================================
// QueueActions Component // QueueActions Component
// ======================================== // ========================================
// Queue operations menu component with delete confirmation and merge dialog // Queue operations with direct action buttons (no dropdown menu)
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { Play, Pause, Trash2, Merge, Loader2 } from 'lucide-react'; import { Play, Pause, Trash2, Merge, GitBranch, Loader2 } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import { import {
AlertDialog, AlertDialog,
AlertDialogContent, AlertDialogContent,
@@ -32,7 +25,9 @@ import {
} from '@/components/ui/Dialog'; } from '@/components/ui/Dialog';
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 type { IssueQueue } from '@/lib/api'; import { Checkbox } from '@/components/ui/Checkbox';
import { cn } from '@/lib/utils';
import type { IssueQueue, QueueItem } from '@/lib/api';
// ========== Types ========== // ========== Types ==========
@@ -43,10 +38,12 @@ export interface QueueActionsProps {
onDeactivate?: () => void; onDeactivate?: () => void;
onDelete?: (queueId: string) => void; onDelete?: (queueId: string) => void;
onMerge?: (sourceId: string, targetId: string) => void; onMerge?: (sourceId: string, targetId: string) => void;
onSplit?: (sourceQueueId: string, itemIds: string[]) => void;
isActivating?: boolean; isActivating?: boolean;
isDeactivating?: boolean; isDeactivating?: boolean;
isDeleting?: boolean; isDeleting?: boolean;
isMerging?: boolean; isMerging?: boolean;
isSplitting?: boolean;
} }
// ========== Component ========== // ========== Component ==========
@@ -58,18 +55,25 @@ export function QueueActions({
onDeactivate, onDeactivate,
onDelete, onDelete,
onMerge, onMerge,
onSplit,
isActivating = false, isActivating = false,
isDeactivating = false, isDeactivating = false,
isDeleting = false, isDeleting = false,
isMerging = false, isMerging = false,
isSplitting = false,
}: QueueActionsProps) { }: QueueActionsProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isMergeOpen, setIsMergeOpen] = useState(false); const [isMergeOpen, setIsMergeOpen] = useState(false);
const [isSplitOpen, setIsSplitOpen] = useState(false);
const [mergeTargetId, setMergeTargetId] = useState(''); const [mergeTargetId, setMergeTargetId] = useState('');
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key // Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
const queueId = queue.tasks.join(',') || queue.solutions.join(','); const queueId = (queue.tasks?.join(',') || queue.solutions?.join(',') || 'default');
// Get all items from grouped_items for split dialog
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();
const handleDelete = () => { const handleDelete = () => {
onDelete?.(queueId); onDelete?.(queueId);
@@ -84,68 +88,122 @@ export function QueueActions({
} }
}; };
const handleSplit = () => {
if (selectedItemIds.length > 0 && selectedItemIds.length < allItems.length) {
onSplit?.(queueId, selectedItemIds);
setIsSplitOpen(false);
setSelectedItemIds([]);
}
};
const toggleItemSelection = (itemId: string) => {
setSelectedItemIds(prev =>
prev.includes(itemId)
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
};
const selectAll = () => {
setSelectedItemIds(allItems.map(item => item.item_id));
};
const clearAll = () => {
setSelectedItemIds([]);
};
// Calculate item count
const totalItems = (queue.tasks?.length || 0) + (queue.solutions?.length || 0);
const canSplit = totalItems > 1;
return ( return (
<> <>
<DropdownMenu> {/* Direct action buttons */}
<DropdownMenuTrigger asChild> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> {/* Activate/Deactivate button */}
<span className="sr-only">{formatMessage({ id: 'common.actions.openMenu' })}</span> {!isActive && onActivate && (
<svg <Button
className="w-4 h-4" variant="ghost"
fill="none" size="sm"
stroke="currentColor" className="h-8 w-8 p-0"
viewBox="0 0 24 24" onClick={() => onActivate(queueId)}
> disabled={isActivating}
<circle cx="12" cy="12" r="1" /> title={formatMessage({ id: 'issues.queue.actions.activate' })}
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isActive && onActivate && (
<DropdownMenuItem onClick={() => onActivate(queueId)} disabled={isActivating}>
{isActivating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Play className="w-4 h-4 mr-2" />
)}
{formatMessage({ id: 'issues.queue.actions.activate' })}
</DropdownMenuItem>
)}
{isActive && onDeactivate && (
<DropdownMenuItem onClick={() => onDeactivate()} disabled={isDeactivating}>
{isDeactivating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Pause className="w-4 h-4 mr-2" />
)}
{formatMessage({ id: 'issues.queue.actions.deactivate' })}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setIsMergeOpen(true)} disabled={isMerging}>
{isMerging ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Merge className="w-4 h-4 mr-2" />
)}
{formatMessage({ id: 'issues.queue.actions.merge' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
className="text-destructive"
> >
{isDeleting ? ( {isActivating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
) : ( ) : (
<Trash2 className="w-4 h-4 mr-2" /> <Play className="w-4 h-4 text-success" />
)} )}
{formatMessage({ id: 'issues.queue.actions.delete' })} </Button>
</DropdownMenuItem> )}
</DropdownMenuContent> {isActive && onDeactivate && (
</DropdownMenu> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onDeactivate()}
disabled={isDeactivating}
title={formatMessage({ id: 'issues.queue.actions.deactivate' })}
>
{isDeactivating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Pause className="w-4 h-4 text-warning" />
)}
</Button>
)}
{/* Merge button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsMergeOpen(true)}
disabled={isMerging}
title={formatMessage({ id: 'issues.queue.actions.merge' })}
>
{isMerging ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Merge className="w-4 h-4 text-info" />
)}
</Button>
{/* Split button - only show if more than 1 item */}
{canSplit && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsSplitOpen(true)}
disabled={isSplitting}
title={formatMessage({ id: 'issues.queue.actions.split' })}
>
{isSplitting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<GitBranch className="w-4 h-4 text-muted-foreground" />
)}
</Button>
)}
{/* Delete button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
title={formatMessage({ id: 'issues.queue.actions.delete' })}
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin text-destructive" />
) : (
<Trash2 className="w-4 h-4 text-destructive" />
)}
</Button>
</div>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}> <AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
@@ -227,6 +285,100 @@ export function QueueActions({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Split Dialog */}
<Dialog open={isSplitOpen} onOpenChange={setIsSplitOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'issues.queue.splitDialog.title' })}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col py-4">
{/* Selection info */}
<div className="flex items-center justify-between mb-4 pb-4 border-b">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'issues.queue.splitDialog.selected' }, { count: selectedItemIds.length, total: allItems.length })}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={selectAll}>
{formatMessage({ id: 'issues.queue.splitDialog.selectAll' })}
</Button>
<Button variant="outline" size="sm" onClick={clearAll}>
{formatMessage({ id: 'issues.queue.splitDialog.clearAll' })}
</Button>
</div>
</div>
{/* Items list with checkboxes */}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allItems.map((item) => {
const isSelected = selectedItemIds.includes(item.item_id);
return (
<div
key={item.item_id}
className={cn(
"flex items-center gap-3 p-3 rounded-md border transition-colors cursor-pointer",
isSelected ? "bg-primary/10 border-primary" : "bg-card hover:bg-muted/50"
)}
onClick={() => toggleItemSelection(item.item_id)}
>
<Checkbox
checked={isSelected}
onChange={() => toggleItemSelection(item.item_id)}
/>
<span className="font-mono text-xs flex-1 truncate">
{item.item_id}
</span>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: `issues.queue.status.${item.status}` })}
</span>
</div>
);
})}
</div>
{/* Validation message */}
{selectedItemIds.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">
{formatMessage({ id: 'issues.queue.splitDialog.noSelection' })}
</p>
)}
{selectedItemIds.length >= allItems.length && (
<p className="text-sm text-destructive text-center py-2">
{formatMessage({ id: 'issues.queue.splitDialog.cannotSplitAll' })}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsSplitOpen(false);
setSelectedItemIds([]);
}}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleSplit}
disabled={selectedItemIds.length === 0 || selectedItemIds.length >= allItems.length || isSplitting}
>
{isSplitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'common.actions.splitting' })}
</>
) : (
<>
<GitBranch className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.queue.actions.split' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@@ -21,10 +21,13 @@ export interface QueueCardProps {
onDeactivate?: () => void; onDeactivate?: () => void;
onDelete?: (queueId: string) => void; onDelete?: (queueId: string) => void;
onMerge?: (sourceId: string, targetId: string) => void; onMerge?: (sourceId: string, targetId: string) => void;
onSplit?: (sourceQueueId: string, itemIds: string[]) => void;
onItemClick?: (item: import('@/lib/api').QueueItem) => void;
isActivating?: boolean; isActivating?: boolean;
isDeactivating?: boolean; isDeactivating?: boolean;
isDeleting?: boolean; isDeleting?: boolean;
isMerging?: boolean; isMerging?: boolean;
isSplitting?: boolean;
className?: string; className?: string;
} }
@@ -37,10 +40,13 @@ export function QueueCard({
onDeactivate, onDeactivate,
onDelete, onDelete,
onMerge, onMerge,
onSplit,
onItemClick,
isActivating = false, isActivating = false,
isDeactivating = false, isDeactivating = false,
isDeleting = false, isDeleting = false,
isMerging = false, isMerging = false,
isSplitting = false,
className, className,
}: QueueCardProps) { }: QueueCardProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@@ -101,10 +107,12 @@ export function QueueCard({
onDeactivate={onDeactivate} onDeactivate={onDeactivate}
onDelete={onDelete} onDelete={onDelete}
onMerge={onMerge} onMerge={onMerge}
onSplit={onSplit}
isActivating={isActivating} isActivating={isActivating}
isDeactivating={isDeactivating} isDeactivating={isDeactivating}
isDeleting={isDeleting} isDeleting={isDeleting}
isMerging={isMerging} isMerging={isMerging}
isSplitting={isSplitting}
/> />
</div> </div>
@@ -143,6 +151,7 @@ export function QueueCard({
group={group.id} group={group.id}
items={group.items} items={group.items}
type={group.type} type={group.type}
onItemClick={onItemClick}
/> />
))} ))}
</div> </div>

View File

@@ -0,0 +1,212 @@
// ========================================
// SolutionDrawer Component
// ========================================
// Right-side solution detail drawer
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { cn } from '@/lib/utils';
import type { QueueItem } from '@/lib/api';
// ========== Types ==========
export interface SolutionDrawerProps {
item: QueueItem | null;
isOpen: boolean;
onClose: () => void;
}
type TabValue = 'overview' | 'tasks' | 'json';
// ========== Status Configuration ==========
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
pending: { label: 'issues.queue.status.pending', variant: 'secondary', icon: Circle },
ready: { label: 'issues.queue.status.ready', variant: 'info', icon: Clock },
executing: { label: 'issues.queue.status.executing', variant: 'warning', icon: Loader2 },
completed: { label: 'issues.queue.status.completed', variant: 'success', icon: CheckCircle },
failed: { label: 'issues.queue.status.failed', variant: 'destructive', icon: XCircle },
blocked: { label: 'issues.queue.status.blocked', variant: 'destructive', icon: AlertTriangle },
};
// ========== Component ==========
export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<TabValue>('overview');
// ESC key to close
useState(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
});
if (!item || !isOpen) {
return null;
}
const status = statusConfig[item.status] || statusConfig.pending;
const StatusIcon = status.icon;
// Get solution details (would need to fetch full solution data)
const solutionId = item.solution_id;
const issueId = item.issue_id;
return (
<>
{/* Overlay */}
<div
className={cn(
'fixed inset-0 bg-black/40 transition-opacity z-40',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={cn(
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
role="dialog"
aria-modal="true"
style={{ minWidth: '400px', maxWidth: '800px' }}
>
{/* Header */}
<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 items-center gap-2 mb-2">
<span className="text-xs font-mono text-muted-foreground">{item.item_id}</span>
<Badge variant={status.variant} className="gap-1">
<StatusIcon className="h-3 w-3" />
{formatMessage({ id: status.label })}
</Badge>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'solution.issue' })}: <span className="font-mono">{issueId}</span>
</p>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'solution.solution' })}: <span className="font-mono">{solutionId}</span>
</p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
<X className="h-5 w-5" />
</Button>
</div>
{/* Tabs */}
<div className="px-6 pt-4 bg-card">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)} className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview" className="flex-1">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'solution.tabs.overview' })}
</TabsTrigger>
<TabsTrigger value="tasks" className="flex-1">
<CheckCircle className="h-4 w-4 mr-2" />
{formatMessage({ id: 'solution.tabs.tasks' })}
</TabsTrigger>
<TabsTrigger value="json" className="flex-1">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'solution.tabs.json' })}
</TabsTrigger>
</TabsList>
{/* Tab Content */}
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
{/* Overview Tab */}
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
<div className="space-y-6">
{/* Execution Info */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
{formatMessage({ id: 'solution.overview.executionInfo' })}
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.executionOrder' })}</p>
<p className="text-lg font-semibold">{item.execution_order}</p>
</div>
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.semanticPriority' })}</p>
<p className="text-lg font-semibold">{item.semantic_priority}</p>
</div>
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.group' })}</p>
<p className="text-sm font-mono truncate">{item.execution_group}</p>
</div>
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.taskCount' })}</p>
<p className="text-lg font-semibold">{item.task_count || '-'}</p>
</div>
</div>
</div>
{/* Dependencies */}
{item.depends_on && item.depends_on.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'solution.overview.dependencies' })}
</h3>
<div className="flex flex-wrap gap-2">
{item.depends_on.map((dep, index) => (
<Badge key={index} variant="outline" className="font-mono text-xs">
{dep}
</Badge>
))}
</div>
</div>
)}
{/* Files Touched */}
{item.files_touched && item.files_touched.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'solution.overview.filesTouched' })}
</h3>
<div className="space-y-1">
{item.files_touched.map((file, index) => (
<div key={index} className="p-2 bg-muted/50 rounded-md">
<span className="text-sm font-mono">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
</TabsContent>
{/* Tasks Tab */}
<TabsContent value="tasks" className="mt-4 pb-6 focus-visible:outline-none">
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'solution.tasks.comingSoon' })}</p>
</div>
</TabsContent>
{/* JSON Tab */}
<TabsContent value="json" className="mt-4 pb-6 focus-visible:outline-none">
<pre className="p-4 bg-muted rounded-md overflow-x-auto text-xs">
{JSON.stringify(item, null, 2)}
</pre>
</TabsContent>
</div>
</Tabs>
</div>
</div>
</>
);
}
export default SolutionDrawer;

View File

@@ -60,8 +60,6 @@ const navItemDefinitions: Omit<NavItem, 'label'>[] = [
{ path: '/orchestrator', icon: Workflow }, { path: '/orchestrator', icon: Workflow },
{ path: '/loops', icon: RefreshCw }, { path: '/loops', icon: RefreshCw },
{ path: '/issues', icon: AlertCircle }, { path: '/issues', icon: AlertCircle },
{ path: '/issues?tab=queue', icon: ListTodo },
{ path: '/issues?tab=discovery', icon: Search },
{ path: '/skills', icon: Sparkles }, { path: '/skills', icon: Sparkles },
{ path: '/commands', icon: Terminal }, { path: '/commands', icon: Terminal },
{ path: '/memory', icon: Brain }, { path: '/memory', icon: Brain },
@@ -69,6 +67,7 @@ const navItemDefinitions: Omit<NavItem, 'label'>[] = [
{ path: '/hooks', icon: GitFork }, { path: '/hooks', icon: GitFork },
{ path: '/settings', icon: Settings }, { path: '/settings', icon: Settings },
{ path: '/settings/rules', icon: Shield }, { path: '/settings/rules', icon: Shield },
{ path: '/settings/codexlens', icon: Sparkles },
{ path: '/help', icon: HelpCircle }, { path: '/help', icon: HelpCircle },
]; ];
@@ -110,8 +109,6 @@ export function Sidebar({
'/orchestrator': 'main.orchestrator', '/orchestrator': 'main.orchestrator',
'/loops': 'main.loops', '/loops': 'main.loops',
'/issues': 'main.issues', '/issues': 'main.issues',
'/issues?tab=queue': 'main.issueQueue',
'/issues?tab=discovery': 'main.issueDiscovery',
'/skills': 'main.skills', '/skills': 'main.skills',
'/commands': 'main.commands', '/commands': 'main.commands',
'/memory': 'main.memory', '/memory': 'main.memory',
@@ -119,6 +116,7 @@ export function Sidebar({
'/hooks': 'main.hooks', '/hooks': 'main.hooks',
'/settings': 'main.settings', '/settings': 'main.settings',
'/settings/rules': 'main.rules', '/settings/rules': 'main.rules',
'/settings/codexlens': 'main.codexlens',
'/help': 'main.help', '/help': 'main.help',
}; };
return navItemDefinitions.map((item) => ({ return navItemDefinitions.map((item) => ({

View File

@@ -0,0 +1,143 @@
// ========================================
// AssetsCard Component
// ========================================
// Displays assets with category tabs and card grid
import { useIntl } from 'react-intl';
import { FileText, Code, TestTube } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
export interface AssetItem {
path: string;
relevance_score?: number;
scope?: string;
contains?: string[];
}
export interface AssetsData {
documentation?: AssetItem[];
source_code?: AssetItem[];
tests?: AssetItem[];
}
export interface AssetsCardProps {
data?: AssetsData;
}
/**
* AssetsCard component - Displays project assets with categorization
*/
export function AssetsCard({ data }: AssetsCardProps) {
const { formatMessage } = useIntl();
if (!data || (!data.documentation?.length && !data.source_code?.length && !data.tests?.length)) {
return null;
}
const docCount = data.documentation?.length || 0;
const sourceCount = data.source_code?.length || 0;
const testCount = data.tests?.length || 0;
const totalAssets = docCount + sourceCount + testCount;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.assets.title' })}
<Badge variant="secondary">{totalAssets}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue={docCount > 0 ? 'documentation' : sourceCount > 0 ? 'source_code' : 'tests'}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="documentation" disabled={!docCount}>
<FileText className="w-4 h-4 mr-1" />
{formatMessage({ id: 'sessionDetail.context.categories.documentation' })}
{docCount > 0 && <span className="ml-1 text-xs">({docCount})</span>}
</TabsTrigger>
<TabsTrigger value="source_code" disabled={!sourceCount}>
<Code className="w-4 h-4 mr-1" />
{formatMessage({ id: 'sessionDetail.context.categories.sourceCode' })}
{sourceCount > 0 && <span className="ml-1 text-xs">({sourceCount})</span>}
</TabsTrigger>
<TabsTrigger value="tests" disabled={!testCount}>
<TestTube className="w-4 h-4 mr-1" />
{formatMessage({ id: 'sessionDetail.context.categories.tests' })}
{testCount > 0 && <span className="ml-1 text-xs">({testCount})</span>}
</TabsTrigger>
</TabsList>
<TabsContent value="documentation" className="mt-4">
<AssetGrid items={data.documentation || []} type="documentation" />
</TabsContent>
<TabsContent value="source_code" className="mt-4">
<AssetGrid items={data.source_code || []} type="source_code" />
</TabsContent>
<TabsContent value="tests" className="mt-4">
<AssetGrid items={data.tests || []} type="tests" />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
interface AssetGridProps {
items: AssetItem[];
type: string;
}
function AssetGrid({ items }: AssetGridProps) {
const { formatMessage } = useIntl();
if (items.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
{formatMessage({ id: 'sessionDetail.context.assets.noData' })}
</div>
);
}
return (
<div className="grid gap-3">
{items.map((item, index) => (
<div
key={index}
className="p-3 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-mono text-foreground truncate">{item.path}</p>
</div>
{item.relevance_score !== undefined && (
<Badge
variant={item.relevance_score > 0.7 ? 'success' : item.relevance_score > 0.4 ? 'default' : 'secondary'}
className="flex-shrink-0"
>
{Math.round(item.relevance_score * 100)}%
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{item.scope && (
<span className="text-muted-foreground">
{formatMessage({ id: 'sessionDetail.context.assets.scope' })}: {item.scope}
</span>
)}
{item.contains && item.contains.length > 0 && (
<span className="text-muted-foreground">
{formatMessage({ id: 'sessionDetail.context.assets.contains' })}: {item.contains.join(', ')}
</span>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,159 @@
// ========================================
// ConflictDetectionCard Component
// ========================================
// Displays conflict detection results with risk levels
import { useIntl } from 'react-intl';
import { AlertTriangle, Shield, AlertCircle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { FieldRenderer } from './FieldRenderer';
export interface RiskFactors {
test_gaps?: string[];
existing_implementations?: string[];
}
export interface ConflictDetectionData {
risk_level?: 'low' | 'medium' | 'high' | 'critical';
mitigation_strategy?: string;
risk_factors?: RiskFactors;
affected_modules?: string[];
}
export interface ConflictDetectionCardProps {
data?: ConflictDetectionData;
}
/**
* ConflictDetectionCard component - Displays conflict detection results
*/
export function ConflictDetectionCard({ data }: ConflictDetectionCardProps) {
const { formatMessage } = useIntl();
if (!data || !data.risk_level) {
return null;
}
const riskConfig = getRiskConfig(data.risk_level);
return (
<Card className={riskConfig.borderClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className={`w-5 h-5 ${riskConfig.iconColor}`} />
{formatMessage({ id: 'sessionDetail.context.conflictDetection.title' })}
<Badge variant={riskConfig.badgeVariant} className={riskConfig.badgeClass}>
{formatMessage({ id: `sessionDetail.context.conflictDetection.riskLevel.${data.risk_level}` })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Mitigation Strategy */}
{data.mitigation_strategy && (
<div className="p-3 bg-muted/50 rounded-lg">
<div className="flex items-start gap-2">
<Shield className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'sessionDetail.context.conflictDetection.mitigation' })}
</h4>
<p className="text-sm text-muted-foreground">{data.mitigation_strategy}</p>
</div>
</div>
</div>
)}
{/* Risk Factors */}
{data.risk_factors && (
<RiskFactorsSection factors={data.risk_factors} />
)}
{/* Affected Modules */}
{data.affected_modules && data.affected_modules.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'sessionDetail.context.conflictDetection.affectedModules' })}
</h4>
<FieldRenderer value={data.affected_modules} type="tags" />
</div>
)}
</CardContent>
</Card>
);
}
interface RiskFactorsSectionProps {
factors: RiskFactors;
}
function RiskFactorsSection({ factors }: RiskFactorsSectionProps) {
const { formatMessage } = useIntl();
const hasTestGaps = factors.test_gaps && factors.test_gaps.length > 0;
const hasExistingImpl = factors.existing_implementations && factors.existing_implementations.length > 0;
if (!hasTestGaps && !hasExistingImpl) {
return null;
}
return (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{formatMessage({ id: 'sessionDetail.context.conflictDetection.riskFactors' })}
</h4>
<div className="space-y-2">
{hasTestGaps && (
<div className="p-2 border-l-2 border-warning bg-warning/5 rounded-r">
<p className="text-xs font-medium text-foreground mb-1">
{formatMessage({ id: 'sessionDetail.context.conflictDetection.testGaps' })}
</p>
<FieldRenderer value={factors.test_gaps!} type="array" />
</div>
)}
{hasExistingImpl && (
<div className="p-2 border-l-2 border-info bg-info/5 rounded-r">
<p className="text-xs font-medium text-foreground mb-1">
{formatMessage({ id: 'sessionDetail.context.conflictDetection.existingImplementations' })}
</p>
<FieldRenderer value={factors.existing_implementations!} type="array" />
</div>
)}
</div>
</div>
);
}
function getRiskConfig(level: string) {
switch (level) {
case 'critical':
return {
borderClass: 'border-destructive',
iconColor: 'text-destructive',
badgeVariant: 'destructive' as const,
badgeClass: 'bg-destructive text-destructive-foreground',
};
case 'high':
return {
borderClass: 'border-warning',
iconColor: 'text-warning',
badgeVariant: 'warning' as const,
badgeClass: '',
};
case 'medium':
return {
borderClass: 'border-info',
iconColor: 'text-info',
badgeVariant: 'info' as const,
badgeClass: '',
};
default:
return {
borderClass: '',
iconColor: 'text-success',
badgeVariant: 'success' as const,
badgeClass: '',
};
}
}

View File

@@ -0,0 +1,146 @@
// ========================================
// DependenciesCard Component
// ========================================
// Displays internal and external dependencies
import { useIntl } from 'react-intl';
import { GitBranch, Package } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
export interface InternalDependency {
from: string;
type: string;
to: string;
}
export interface ExternalDependency {
package: string;
version?: string;
usage?: string;
}
export interface DependenciesData {
internal?: InternalDependency[];
external?: ExternalDependency[];
}
export interface DependenciesCardProps {
data?: DependenciesData;
}
/**
* DependenciesCard component - Displays project dependencies
*/
export function DependenciesCard({ data }: DependenciesCardProps) {
const { formatMessage } = useIntl();
if (!data || (!data.internal?.length && !data.external?.length)) {
return null;
}
const internalCount = data.internal?.length || 0;
const externalCount = data.external?.length || 0;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.dependencies.title' })}
<Badge variant="secondary">{internalCount + externalCount}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{data.internal && data.internal.length > 0 && (
<InternalDependenciesSection dependencies={data.internal} />
)}
{data.external && data.external.length > 0 && (
<ExternalDependenciesSection dependencies={data.external} />
)}
</CardContent>
</Card>
);
}
interface InternalDependenciesSectionProps {
dependencies: InternalDependency[];
}
function InternalDependenciesSection({ dependencies }: InternalDependenciesSectionProps) {
const { formatMessage } = useIntl();
return (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
<GitBranch className="w-4 h-4" />
{formatMessage({ id: 'sessionDetail.context.dependencies.internal' })} ({dependencies.length})
</h4>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium text-foreground">
{formatMessage({ id: 'sessionDetail.context.dependencies.from' })}
</th>
<th className="px-4 py-2 text-left font-medium text-foreground">
{formatMessage({ id: 'sessionDetail.context.dependencies.type' })}
</th>
<th className="px-4 py-2 text-left font-medium text-foreground">
{formatMessage({ id: 'sessionDetail.context.dependencies.to' })}
</th>
</tr>
</thead>
<tbody className="divide-y">
{dependencies.map((dep, index) => (
<tr key={index} className="hover:bg-muted/50">
<td className="px-4 py-2 font-mono text-foreground">{dep.from}</td>
<td className="px-4 py-2">
<Badge variant="outline">{dep.type}</Badge>
</td>
<td className="px-4 py-2 font-mono text-foreground">{dep.to}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
interface ExternalDependenciesSectionProps {
dependencies: ExternalDependency[];
}
function ExternalDependenciesSection({ dependencies }: ExternalDependenciesSectionProps) {
const { formatMessage } = useIntl();
return (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
<Package className="w-4 h-4" />
{formatMessage({ id: 'sessionDetail.context.dependencies.external' })} ({dependencies.length})
</h4>
<div className="flex flex-wrap gap-2">
{dependencies.map((dep, index) => (
<Badge key={index} variant="secondary" className="px-3 py-1.5">
{dep.package}
{dep.version && <span className="ml-1 text-muted-foreground">@{dep.version}</span>}
</Badge>
))}
</div>
{dependencies.some(d => d.usage) && (
<div className="mt-3 space-y-1">
{dependencies
.filter(d => d.usage)
.map((dep, index) => (
<div key={index} className="text-xs text-muted-foreground">
<span className="font-medium">{dep.package}:</span> {dep.usage}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
// ========================================
// ExplorationCollapsible Component
// ========================================
// Collapsible section for exploration angles
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/Collapsible';
import { cn } from '@/lib/utils';
export interface ExplorationCollapsibleProps {
title: string;
icon?: React.ReactNode;
defaultOpen?: boolean;
children: React.ReactNode;
className?: string;
}
/**
* ExplorationCollapsible component - Collapsible section for exploration data
*/
export function ExplorationCollapsible({
title,
icon,
defaultOpen = false,
children,
className,
}: ExplorationCollapsibleProps) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className={cn('border rounded-lg', className)}>
<CollapsibleTrigger className="flex items-center justify-between w-full p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2">
{icon}
<span className="font-medium text-foreground">{title}</span>
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen && 'transform rotate-180'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="p-3 pt-0">
<div className="mt-2 space-y-2">
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,183 @@
// ========================================
// ExplorationsSection Component
// ========================================
// Displays exploration data with collapsible sections
import { useIntl } from 'react-intl';
import {
GitBranch,
Search,
Link,
TestTube,
FolderOpen,
FileText,
Layers
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { ExplorationCollapsible } from './ExplorationCollapsible';
import { FieldRenderer } from './FieldRenderer';
export interface ExplorationsData {
manifest: {
task_description: string;
complexity?: string;
exploration_count: number;
};
data: Record<string, {
project_structure?: string[];
relevant_files?: string[];
patterns?: string[];
dependencies?: string[];
integration_points?: string[];
testing?: string[];
}>;
}
export interface ExplorationsSectionProps {
data?: ExplorationsData;
}
/**
* ExplorationsSection component - Displays all exploration angles
*/
export function ExplorationsSection({ data }: ExplorationsSectionProps) {
const { formatMessage } = useIntl();
if (!data || !data.data || Object.keys(data.data).length === 0) {
return null;
}
const explorationEntries = Object.entries(data.data);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.explorations.title' })}
<span className="text-sm font-normal text-muted-foreground">
({data.manifest.exploration_count} {formatMessage({ id: 'sessionDetail.context.explorations.angles' })})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{explorationEntries.map(([angle, angleData]) => (
<ExplorationCollapsible
key={angle}
title={formatAngleTitle(angle)}
icon={<Search className="w-4 h-4 text-muted-foreground" />}
>
<AngleContent data={angleData} />
</ExplorationCollapsible>
))}
</div>
</CardContent>
</Card>
);
}
interface AngleContentProps {
data: {
project_structure?: string[];
relevant_files?: string[];
patterns?: string[];
dependencies?: string[];
integration_points?: string[];
testing?: string[];
};
}
function AngleContent({ data }: AngleContentProps) {
const { formatMessage } = useIntl();
const sections: Array<{
key: string;
icon: JSX.Element;
label: string;
data: unknown;
}> = [];
if (data.project_structure && data.project_structure.length > 0) {
sections.push({
key: 'project_structure',
icon: <FolderOpen className="w-4 h-4" />,
label: formatMessage({ id: 'sessionDetail.context.explorations.projectStructure' }),
data: data.project_structure,
});
}
if (data.relevant_files && data.relevant_files.length > 0) {
sections.push({
key: 'relevant_files',
icon: <FileText className="w-4 h-4" />,
label: formatMessage({ id: 'sessionDetail.context.explorations.relevantFiles' }),
data: data.relevant_files,
});
}
if (data.patterns && data.patterns.length > 0) {
sections.push({
key: 'patterns',
icon: <Layers className="w-4 h-4" />,
label: formatMessage({ id: 'sessionDetail.context.explorations.patterns' }),
data: data.patterns,
});
}
if (data.dependencies && data.dependencies.length > 0) {
sections.push({
key: 'dependencies',
icon: <GitBranch className="w-4 h-4" />,
label: formatMessage({ id: 'sessionDetail.context.explorations.dependencies' }),
data: data.dependencies,
});
}
if (data.integration_points && data.integration_points.length > 0) {
sections.push({
key: 'integration_points',
icon: <Link className="w-4 h-4" />,
label: formatMessage({ id: 'sessionDetail.context.explorations.integrationPoints' }),
data: data.integration_points,
});
}
if (data.testing && data.testing.length > 0) {
sections.push({
key: 'testing',
icon: <TestTube className="w-4 h-4" />,
label: formatMessage({ id: 'sessionDetail.context.explorations.testing' }),
data: data.testing,
});
}
if (sections.length === 0) {
return <p className="text-sm text-muted-foreground italic">No data available</p>;
}
return (
<div className="space-y-3">
{sections.map((section) => (
<div key={section.key} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5">{section.icon}</span>
<div className="flex-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
{section.label}
</p>
<FieldRenderer value={section.data} type="array" />
</div>
</div>
))}
</div>
);
}
function formatAngleTitle(angle: string): string {
return angle
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.trim()
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,143 @@
// ========================================
// FieldRenderer Component
// ========================================
// Renders various data types for context display
import { FileText } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
export interface FieldRendererProps {
value: unknown;
type?: 'string' | 'array' | 'object' | 'files' | 'tags' | 'auto';
className?: string;
}
/**
* FieldRenderer component - Automatically renders different data types
*/
export function FieldRenderer({ value, type = 'auto', className }: FieldRendererProps) {
if (value === null || value === undefined) {
return <span className="text-muted-foreground italic">-</span>;
}
const detectedType = type === 'auto' ? detectType(value) : type;
switch (detectedType) {
case 'array':
return <ArrayRenderer value={value as unknown[]} className={className} />;
case 'object':
return <ObjectRenderer value={value as Record<string, unknown>} className={className} />;
case 'files':
return <FilesRenderer value={value as Array<{ path: string }>} className={className} />;
case 'tags':
return <TagsRenderer value={value as string[]} className={className} />;
default:
return <StringRenderer value={String(value)} className={className} />;
}
}
function detectType(value: unknown): 'string' | 'array' | 'object' | 'files' | 'tags' {
if (Array.isArray(value)) {
if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null && 'path' in value[0]) {
return 'files';
}
if (value.length > 0 && typeof value[0] === 'string') {
return 'tags';
}
return 'array';
}
if (typeof value === 'object' && value !== null) {
return 'object';
}
return 'string';
}
function StringRenderer({ value, className }: { value: string; className?: string }) {
return <span className={cn('text-foreground', className)}>{value}</span>;
}
function ArrayRenderer({ value, className }: { value: unknown[]; className?: string }) {
if (value.length === 0) {
return <span className="text-muted-foreground italic">Empty</span>;
}
return (
<ul className={cn('space-y-1', className)}>
{value.map((item, index) => (
<li key={index} className="text-sm text-foreground flex items-start gap-2">
<span className="text-muted-foreground">{index + 1}.</span>
<span className="flex-1">{String(item)}</span>
</li>
))}
</ul>
);
}
function ObjectRenderer({ value, className }: { value: Record<string, unknown>; className?: string }) {
const entries = Object.entries(value).filter(([_, v]) => v !== null && v !== undefined);
if (entries.length === 0) {
return <span className="text-muted-foreground italic">Empty</span>;
}
return (
<div className={cn('space-y-2', className)}>
{entries.map(([key, val]) => (
<div key={key} className="flex items-start gap-2">
<span className="text-sm font-medium text-muted-foreground min-w-[100px] capitalize">
{formatLabel(key)}:
</span>
<span className="text-sm text-foreground flex-1">
{typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val)}
</span>
</div>
))}
</div>
);
}
function FilesRenderer({ value, className }: { value: Array<{ path: string }>; className?: string }) {
if (value.length === 0) {
return <span className="text-muted-foreground italic">No files</span>;
}
return (
<div className={cn('space-y-1', className)}>
{value.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted rounded text-sm font-mono text-foreground"
>
<FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="truncate">{file.path}</span>
</div>
))}
</div>
);
}
function TagsRenderer({ value, className }: { value: string[]; className?: string }) {
if (value.length === 0) {
return <span className="text-muted-foreground italic">No tags</span>;
}
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{value.map((tag, index) => (
<Badge key={index} variant="outline" className="px-2 py-0.5">
{tag}
</Badge>
))}
</div>
);
}
function formatLabel(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.trim()
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,179 @@
// ========================================
// TestContextCard Component
// ========================================
// Displays test context with stats and framework info
import { useIntl } from 'react-intl';
import { TestTube, CheckCircle, AlertCircle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { FieldRenderer } from './FieldRenderer';
export interface TestFramework {
name?: string;
plugins?: string[];
}
export interface FrameworkConfig {
backend?: TestFramework;
frontend?: TestFramework;
}
export interface TestContextData {
frameworks?: FrameworkConfig;
existing_tests?: string[];
coverage_config?: Record<string, unknown>;
test_markers?: string[];
}
export interface TestContextCardProps {
data?: TestContextData;
}
/**
* TestContextCard component - Displays testing context and frameworks
*/
export function TestContextCard({ data }: TestContextCardProps) {
const { formatMessage } = useIntl();
if (!data || (!data.frameworks && !data.existing_tests?.length && !data.test_markers?.length)) {
return null;
}
const testCount = data.existing_tests?.length || 0;
const markerCount = data.test_markers?.length || 0;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TestTube className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.testContext.title' })}
{testCount > 0 && (
<Badge variant="secondary">{testCount} {formatMessage({ id: 'sessionDetail.context.testContext.tests' })}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Row */}
{(testCount > 0 || markerCount > 0) && (
<div className="flex gap-4">
{testCount > 0 && (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-success" />
<span className="text-sm text-foreground">
{testCount} {formatMessage({ id: 'sessionDetail.context.testContext.existingTests' })}
</span>
</div>
)}
{markerCount > 0 && (
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-warning" />
<span className="text-sm text-foreground">
{markerCount} {formatMessage({ id: 'sessionDetail.context.testContext.markers' })}
</span>
</div>
)}
</div>
)}
{/* Framework Cards */}
{data.frameworks && (
<FrameworkSection frameworks={data.frameworks} />
)}
{/* Test Markers */}
{data.test_markers && data.test_markers.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'sessionDetail.context.testContext.markers' })}
</h4>
<div className="flex flex-wrap gap-2">
{data.test_markers.map((marker, index) => (
<Badge key={index} variant="info" className="px-2 py-0.5">
{marker}
</Badge>
))}
</div>
</div>
)}
{/* Coverage Config */}
{data.coverage_config && Object.keys(data.coverage_config).length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'sessionDetail.context.testContext.coverage' })}
</h4>
<FieldRenderer value={data.coverage_config} type="object" />
</div>
)}
{/* Existing Tests List */}
{data.existing_tests && data.existing_tests.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'sessionDetail.context.testContext.existingTests' })}
</h4>
<FieldRenderer value={data.existing_tests} type="array" />
</div>
)}
</CardContent>
</Card>
);
}
interface FrameworkSectionProps {
frameworks: FrameworkConfig;
}
function FrameworkSection({ frameworks }: FrameworkSectionProps) {
const { formatMessage } = useIntl();
return (
<div className="grid gap-3 md:grid-cols-2">
{frameworks.backend && (
<FrameworkCard
title={formatMessage({ id: 'sessionDetail.context.testContext.backend' })}
framework={frameworks.backend}
/>
)}
{frameworks.frontend && (
<FrameworkCard
title={formatMessage({ id: 'sessionDetail.context.testContext.frontend' })}
framework={frameworks.frontend}
/>
)}
</div>
);
}
interface FrameworkCardProps {
title: string;
framework: TestFramework;
}
function FrameworkCard({ title, framework }: FrameworkCardProps) {
const { formatMessage } = useIntl();
return (
<div className="p-3 border rounded-lg">
<div className="text-sm font-medium text-foreground mb-2">{title}</div>
<div className="space-y-1">
{framework.name && (
<div className="text-xs text-muted-foreground">
{formatMessage({ id: 'sessionDetail.context.testContext.framework' })}: {framework.name}
</div>
)}
{framework.plugins && framework.plugins.length > 0 && (
<div className="flex flex-wrap gap-1">
{framework.plugins.map((plugin, index) => (
<Badge key={index} variant="outline" className="text-xs">
{plugin}
</Badge>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
// ========================================
// Context Components Exports
// ========================================
export { FieldRenderer } from './FieldRenderer';
export type { FieldRendererProps } from './FieldRenderer';
export { ExplorationCollapsible } from './ExplorationCollapsible';
export type { ExplorationCollapsibleProps } from './ExplorationCollapsible';
export { ExplorationsSection } from './ExplorationsSection';
export type { ExplorationsSectionProps, ExplorationsData } from './ExplorationsSection';
export { AssetsCard } from './AssetsCard';
export type { AssetsCardProps, AssetsData, AssetItem } from './AssetsCard';
export { DependenciesCard } from './DependenciesCard';
export type { DependenciesCardProps, DependenciesData, InternalDependency, ExternalDependency } from './DependenciesCard';
export { TestContextCard } from './TestContextCard';
export type { TestContextCardProps, TestContextData, TestFramework, FrameworkConfig } from './TestContextCard';
export { ConflictDetectionCard } from './ConflictDetectionCard';
export type { ConflictDetectionCardProps, ConflictDetectionData, RiskFactors } from './ConflictDetectionCard';

View File

@@ -0,0 +1,46 @@
// ========================================
// BulkActionButton Component
// ========================================
// Reusable button component for bulk actions
import { Loader2 } from 'lucide-react';
import { Button, ButtonProps } from '@/components/ui/Button';
import type { LucideIcon } from 'lucide-react';
export interface BulkActionButtonProps extends Omit<ButtonProps, 'leftIcon'> {
icon: LucideIcon;
label: string;
isLoading?: boolean;
disabled?: boolean;
}
/**
* BulkActionButton component - Button with icon for bulk actions
*/
export function BulkActionButton({
icon: Icon,
label,
isLoading = false,
disabled = false,
variant = 'default',
size = 'sm',
className = '',
...props
}: BulkActionButtonProps) {
return (
<Button
variant={variant}
size={size}
disabled={disabled || isLoading}
className={className}
{...props}
>
{isLoading ? (
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
) : (
<Icon className="h-4 w-4 mr-1.5" />
)}
{label}
</Button>
);
}

View File

@@ -0,0 +1,94 @@
// ========================================
// TaskStatsBar Component
// ========================================
// Statistics bar with bulk action buttons for tasks
import { useIntl } from 'react-intl';
import { CheckCircle, Loader2, Circle } from 'lucide-react';
import { BulkActionButton } from './BulkActionButton';
import { cn } from '@/lib/utils';
export interface TaskStatsBarProps {
completed: number;
inProgress: number;
pending: number;
onMarkAllPending?: () => void | Promise<void>;
onMarkAllInProgress?: () => void | Promise<void>;
onMarkAllCompleted?: () => void | Promise<void>;
isLoadingPending?: boolean;
isLoadingInProgress?: boolean;
isLoadingCompleted?: boolean;
className?: string;
}
/**
* TaskStatsBar component - Display task statistics with bulk action buttons
*/
export function TaskStatsBar({
completed,
inProgress,
pending,
onMarkAllPending,
onMarkAllInProgress,
onMarkAllCompleted,
isLoadingPending = false,
isLoadingInProgress = false,
isLoadingCompleted = false,
className = '',
}: TaskStatsBarProps) {
const { formatMessage } = useIntl();
return (
<div className={cn('flex flex-wrap items-center gap-4 p-4 bg-background rounded-lg border', className)}>
{/* Statistics */}
<div className="flex flex-wrap items-center gap-4 flex-1">
<span className="flex items-center gap-1.5 text-sm">
<CheckCircle className="h-4 w-4 text-success" />
<strong>{completed}</strong> {formatMessage({ id: 'sessionDetail.tasks.completed' })}
</span>
<span className="flex items-center gap-1.5 text-sm">
<Loader2 className="h-4 w-4 text-warning" />
<strong>{inProgress}</strong> {formatMessage({ id: 'sessionDetail.tasks.inProgress' })}
</span>
<span className="flex items-center gap-1.5 text-sm">
<Circle className="h-4 w-4 text-muted-foreground" />
<strong>{pending}</strong> {formatMessage({ id: 'sessionDetail.tasks.pending' })}
</span>
</div>
{/* Bulk Action Buttons */}
<div className="flex flex-wrap items-center gap-2">
{onMarkAllPending && (
<BulkActionButton
icon={Circle}
label={formatMessage({ id: 'sessionDetail.tasks.quickActions.markAllPending' })}
onClick={onMarkAllPending}
isLoading={isLoadingPending}
disabled={pending === 0}
variant="outline"
/>
)}
{onMarkAllInProgress && (
<BulkActionButton
icon={Loader2}
label={formatMessage({ id: 'sessionDetail.tasks.quickActions.markAllInProgress' })}
onClick={onMarkAllInProgress}
isLoading={isLoadingInProgress}
disabled={inProgress === 0}
variant="outline"
/>
)}
{onMarkAllCompleted && (
<BulkActionButton
icon={CheckCircle}
label={formatMessage({ id: 'sessionDetail.tasks.quickActions.markAllCompleted' })}
onClick={onMarkAllCompleted}
isLoading={isLoadingCompleted}
disabled={completed === 0}
variant="outline"
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
// ========================================
// TaskStatusDropdown Component
// ========================================
// Inline status dropdown for task items
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Circle,
Loader2,
CheckCircle,
CircleX,
Forward,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/Dropdown';
import { Badge } from '@/components/ui/Badge';
import type { TaskStatus } from '@/lib/api';
export interface TaskStatusDropdownProps {
currentStatus: TaskStatus;
onStatusChange: (newStatus: TaskStatus) => void | Promise<void>;
disabled?: boolean;
size?: 'sm' | 'default';
}
// Status configuration
const statusConfig: Record<
TaskStatus,
{
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' | null;
icon: React.ComponentType<{ className?: string }>;
}
> = {
pending: {
label: 'sessionDetail.tasks.status.pending',
variant: 'secondary',
icon: Circle,
},
in_progress: {
label: 'sessionDetail.tasks.status.inProgress',
variant: 'warning',
icon: Loader2,
},
completed: {
label: 'sessionDetail.tasks.status.completed',
variant: 'success',
icon: CheckCircle,
},
blocked: {
label: 'sessionDetail.tasks.status.blocked',
variant: 'destructive',
icon: CircleX,
},
skipped: {
label: 'sessionDetail.tasks.status.skipped',
variant: 'default',
icon: Forward,
},
};
/**
* TaskStatusDropdown component - Inline status selector with optimistic UI
*/
export function TaskStatusDropdown({
currentStatus,
onStatusChange,
disabled = false,
size = 'sm',
}: TaskStatusDropdownProps) {
const { formatMessage } = useIntl();
const [isChanging, setIsChanging] = useState(false);
const handleStatusChange = async (newStatus: TaskStatus) => {
if (newStatus === currentStatus || isChanging) return;
setIsChanging(true);
try {
await onStatusChange(newStatus);
} catch (error) {
console.error('[TaskStatusDropdown] Failed to update status:', error);
} finally {
setIsChanging(false);
}
};
const currentConfig = statusConfig[currentStatus] || statusConfig.pending;
const StatusIcon = currentConfig.icon;
const badgeSize = size === 'sm' ? 'text-xs' : 'text-sm';
return (
<DropdownMenu>
<DropdownMenuTrigger
asChild
disabled={disabled || isChanging}
className="cursor-pointer"
>
<Badge
variant={currentConfig.variant}
className={`gap-1 ${badgeSize} ${isChanging ? 'opacity-50' : ''}`}
>
{isChanging ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<StatusIcon className="h-3 w-3" />
)}
{formatMessage({ id: currentConfig.label })}
</Badge>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[160px]">
{(Object.keys(statusConfig) as TaskStatus[]).map((status) => {
const config = statusConfig[status];
const Icon = config.icon;
return (
<DropdownMenuItem
key={status}
onClick={() => handleStatusChange(status)}
disabled={status === currentStatus || isChanging}
className="gap-2"
>
<Icon className="h-4 w-4" />
<span>{formatMessage({ id: config.label })}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,12 @@
// ========================================
// Task Components Index
// ========================================
// Exports for session-detail task components
export { BulkActionButton } from './BulkActionButton';
export { TaskStatsBar } from './TaskStatsBar';
export { TaskStatusDropdown } from './TaskStatusDropdown';
export type { BulkActionButtonProps } from './BulkActionButton';
export type { TaskStatsBarProps } from './TaskStatsBar';
export type { TaskStatusDropdownProps } from './TaskStatusDropdown';

View File

@@ -0,0 +1,245 @@
// ========================================
// MarkdownModal Component
// ========================================
// Modal for viewing markdown, JSON, or text content with copy and download actions
import * as React from 'react';
import { FileText, Copy, Download, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { useNotifications } from '@/hooks/useNotifications';
import { cn } from '@/lib/utils';
// ========================================
// Types
// ========================================
export type ContentType = 'markdown' | 'json' | 'text';
export interface MarkdownModalProps {
/** Whether the modal is open */
isOpen: boolean;
/** Called when modal is closed */
onClose: () => void;
/** Title displayed in modal header */
title: string;
/** Content to display */
content: string;
/** Type of content for appropriate rendering */
contentType?: ContentType;
/** Maximum width of the modal */
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
/** Maximum height of content area */
maxHeight?: string;
/** Optional custom actions */
actions?: ModalAction[];
/** Whether content is loading */
isLoading?: boolean;
}
export interface ModalAction {
label: string;
icon?: React.ComponentType<{ className?: string }>;
onClick: (content: string) => void | Promise<void>;
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'success';
disabled?: boolean;
}
// ========================================
// Component
// ========================================
/**
* Modal for viewing markdown, JSON, or text content
*
* @example
* ```tsx
* <MarkdownModal
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
* title="IMPL_PLAN.md"
* content={implPlanContent}
* contentType="markdown"
* />
* ```
*/
export function MarkdownModal({
isOpen,
onClose,
title,
content,
contentType = 'markdown',
maxWidth = '2xl',
maxHeight = '60vh',
actions,
isLoading = false,
}: MarkdownModalProps) {
const { success, error } = useNotifications();
const [isCopying, setIsCopying] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const handleCopy = async () => {
setIsCopying(true);
try {
await navigator.clipboard.writeText(content);
success('Copied', 'Content copied to clipboard');
} catch (err) {
error('Error', 'Failed to copy content');
} finally {
setIsCopying(false);
}
};
const handleDownload = () => {
setIsDownloading(true);
try {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const extension = contentType === 'json' ? 'json' : 'md';
a.download = `${title.replace(/[^a-z0-9]/gi, '-')}.${extension}`;
a.click();
URL.revokeObjectURL(url);
success('Downloaded', `File ${title} downloaded`);
} catch (err) {
error('Error', 'Failed to download content');
} finally {
setIsDownloading(false);
}
};
const defaultActions: ModalAction[] = [
{
label: 'Copy',
icon: Copy,
onClick: handleCopy,
variant: 'outline',
disabled: isCopying || isLoading || !content,
},
{
label: 'Download',
icon: Download,
onClick: handleDownload,
variant: 'outline',
disabled: isDownloading || isLoading || !content,
},
];
const modalActions = actions || defaultActions;
const renderContent = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading...</span>
</div>
);
}
if (!content) {
return (
<div className="flex items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<div>
<p className="text-muted-foreground">No content available</p>
</div>
</div>
);
}
switch (contentType) {
case 'markdown':
return (
<div className="prose prose-sm max-w-none dark:prose-invert">
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
{content}
</pre>
</div>
);
case 'json':
return (
<pre className="text-sm bg-muted p-4 rounded-lg overflow-x-auto font-mono">
{JSON.stringify(JSON.parse(content), null, 2)}
</pre>
);
case 'text':
return (
<pre className="text-sm whitespace-pre-wrap break-words font-sans leading-relaxed">
{content}
</pre>
);
default:
return <pre className="text-sm">{content}</pre>;
}
};
const maxWidthClass = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
}[maxWidth];
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={cn('flex flex-col', maxWidthClass)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
{title}
</DialogTitle>
</DialogHeader>
<div
className="flex-1 overflow-auto py-4"
style={{ maxHeight }}
>
{renderContent()}
</div>
<DialogFooter className="gap-2">
{modalActions.map((action, index) => {
const Icon = action.icon;
return (
<Button
key={index}
variant={action.variant || 'default'}
onClick={() => action.onClick(content)}
disabled={action.disabled || isLoading}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{action.label}
{(isCopying && action.label === 'Copy') && (
<Loader2 className="w-4 h-4 ml-2 animate-spin" />
)}
{(isDownloading && action.label === 'Download') && (
<Loader2 className="w-4 h-4 ml-2 animate-spin" />
)}
</Button>
);
})}
<Button variant="ghost" onClick={onClose} disabled={isLoading}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ========================================
// Exports
// ========================================
export default MarkdownModal;

View File

@@ -9,6 +9,9 @@ export type { ButtonProps } from "./Button";
export { Input } from "./Input"; export { Input } from "./Input";
export type { InputProps } from "./Input"; export type { InputProps } from "./Input";
// Checkbox
export { Checkbox } from "./Checkbox";
// Select (Radix) // Select (Radix)
export { export {
Select, Select,

View File

@@ -195,4 +195,55 @@ export {
} from './useWorkspaceQueryKeys'; } from './useWorkspaceQueryKeys';
export type { export type {
WorkspaceQueryKeys, WorkspaceQueryKeys,
} from './useWorkspaceQueryKeys'; } from './useWorkspaceQueryKeys';
// ========== CodexLens ==========
export {
useCodexLensDashboard,
useCodexLensStatus,
useCodexLensWorkspaceStatus,
useCodexLensConfig,
useCodexLensModels,
useCodexLensModelInfo,
useCodexLensEnv,
useCodexLensGpu,
useCodexLensIgnorePatterns,
useUpdateCodexLensConfig,
useBootstrapCodexLens,
useUninstallCodexLens,
useDownloadModel,
useDeleteModel,
useUpdateCodexLensEnv,
useSelectGpu,
useUpdateIgnorePatterns,
useCodexLensMutations,
codexLensKeys,
} from './useCodexLens';
export type {
UseCodexLensDashboardOptions,
UseCodexLensDashboardReturn,
UseCodexLensStatusOptions,
UseCodexLensStatusReturn,
UseCodexLensWorkspaceStatusOptions,
UseCodexLensWorkspaceStatusReturn,
UseCodexLensConfigOptions,
UseCodexLensConfigReturn,
UseCodexLensModelsOptions,
UseCodexLensModelsReturn,
UseCodexLensModelInfoOptions,
UseCodexLensModelInfoReturn,
UseCodexLensEnvOptions,
UseCodexLensEnvReturn,
UseCodexLensGpuOptions,
UseCodexLensGpuReturn,
UseCodexLensIgnorePatternsOptions,
UseCodexLensIgnorePatternsReturn,
UseUpdateCodexLensConfigReturn,
UseBootstrapCodexLensReturn,
UseUninstallCodexLensReturn,
UseDownloadModelReturn,
UseDeleteModelReturn,
UseUpdateCodexLensEnvReturn,
UseSelectGpuReturn,
UseUpdateIgnorePatternsReturn,
} from './useCodexLens';

View File

@@ -388,13 +388,11 @@ export function useRules(options: UseRulesOptions = {}): UseRulesReturn {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath); const projectPath = useWorkflowStore(selectProjectPath);
const queryEnabled = enabled && !!projectPath;
const query = useQuery({ const query = useQuery({
queryKey: workspaceQueryKeys.rulesList(projectPath), queryKey: workspaceQueryKeys.rulesList(projectPath),
queryFn: () => fetchRules(projectPath), queryFn: () => fetchRules(projectPath),
staleTime, staleTime,
enabled: queryEnabled, enabled: enabled, // Remove projectPath requirement
retry: 2, retry: 2,
}); });

View File

@@ -0,0 +1,427 @@
// ========================================
// useCodexLens Hook Tests
// ========================================
// Tests for all CodexLens TanStack Query hooks
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as api from '../lib/api';
import {
useCodexLensDashboard,
useCodexLensStatus,
useCodexLensConfig,
useCodexLensModels,
useCodexLensEnv,
useCodexLensGpu,
useUpdateCodexLensConfig,
useBootstrapCodexLens,
useUninstallCodexLens,
useDownloadModel,
useDeleteModel,
useUpdateCodexLensEnv,
useSelectGpu,
} from './useCodexLens';
// Mock api module
vi.mock('../lib/api', () => ({
fetchCodexLensDashboardInit: vi.fn(),
fetchCodexLensStatus: vi.fn(),
fetchCodexLensConfig: vi.fn(),
updateCodexLensConfig: vi.fn(),
bootstrapCodexLens: vi.fn(),
uninstallCodexLens: vi.fn(),
fetchCodexLensModels: vi.fn(),
fetchCodexLensModelInfo: vi.fn(),
downloadCodexLensModel: vi.fn(),
downloadCodexLensCustomModel: vi.fn(),
deleteCodexLensModel: vi.fn(),
deleteCodexLensModelByPath: vi.fn(),
fetchCodexLensEnv: vi.fn(),
updateCodexLensEnv: vi.fn(),
fetchCodexLensGpuDetect: vi.fn(),
fetchCodexLensGpuList: vi.fn(),
selectCodexLensGpu: vi.fn(),
resetCodexLensGpu: vi.fn(),
fetchCodexLensIgnorePatterns: vi.fn(),
updateCodexLensIgnorePatterns: vi.fn(),
}));
// Mock workflowStore
vi.mock('../stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => () => '/test/project'),
selectProjectPath: vi.fn(() => '/test/project'),
}));
const mockDashboardData = {
installed: true,
status: {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
},
config: {
index_dir: '~/.codexlens/indexes',
index_count: 100,
api_max_workers: 4,
api_batch_size: 8,
},
semantic: { available: true },
};
const mockModelsData = {
models: [
{
profile: 'model1',
name: 'Embedding Model 1',
type: 'embedding',
backend: 'onnx',
installed: true,
cache_path: '/path/to/cache1',
},
{
profile: 'model2',
name: 'Reranker Model 1',
type: 'reranker',
backend: 'onnx',
installed: false,
cache_path: '/path/to/cache2',
},
],
};
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
function wrapper({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
describe('useCodexLens Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCodexLensDashboard', () => {
it('should fetch dashboard data', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledOnce();
expect(result.current.installed).toBe(true);
expect(result.current.status?.ready).toBe(true);
expect(result.current.config?.index_dir).toBe('~/.codexlens/indexes');
});
it('should handle errors', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockRejectedValue(new Error('API Error'));
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBeTruthy();
expect(result.current.error?.message).toBe('API Error');
});
it('should be disabled when enabled is false', async () => {
const { result } = renderHook(() => useCodexLensDashboard({ enabled: false }), { wrapper });
expect(api.fetchCodexLensDashboardInit).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
});
});
describe('useCodexLensStatus', () => {
it('should fetch status data', async () => {
const mockStatus = { ready: true, installed: true, version: '1.0.0' };
vi.mocked(api.fetchCodexLensStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useCodexLensStatus(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensStatus).toHaveBeenCalledOnce();
expect(result.current.ready).toBe(true);
expect(result.current.installed).toBe(true);
});
});
describe('useCodexLensConfig', () => {
it('should fetch config data', async () => {
const mockConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
api_max_workers: 4,
api_batch_size: 8,
};
vi.mocked(api.fetchCodexLensConfig).mockResolvedValue(mockConfig);
const { result } = renderHook(() => useCodexLensConfig(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensConfig).toHaveBeenCalledOnce();
expect(result.current.indexDir).toBe('~/.codexlens/indexes');
expect(result.current.indexCount).toBe(100);
expect(result.current.apiMaxWorkers).toBe(4);
expect(result.current.apiBatchSize).toBe(8);
});
});
describe('useCodexLensModels', () => {
it('should fetch and filter models by type', async () => {
vi.mocked(api.fetchCodexLensModels).mockResolvedValue(mockModelsData);
const { result } = renderHook(() => useCodexLensModels(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.models).toHaveLength(2);
expect(result.current.embeddingModels).toHaveLength(1);
expect(result.current.rerankerModels).toHaveLength(1);
expect(result.current.embeddingModels?.[0].type).toBe('embedding');
});
});
describe('useCodexLensEnv', () => {
it('should fetch environment variables', async () => {
const mockEnv = {
env: { KEY1: 'value1', KEY2: 'value2' },
settings: { SETTING1: 'setting1' },
raw: 'KEY1=value1\nKEY2=value2',
};
vi.mocked(api.fetchCodexLensEnv).mockResolvedValue(mockEnv);
const { result } = renderHook(() => useCodexLensEnv(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensEnv).toHaveBeenCalledOnce();
expect(result.current.env).toEqual({ KEY1: 'value1', KEY2: 'value2' });
expect(result.current.settings).toEqual({ SETTING1: 'setting1' });
expect(result.current.raw).toBe('KEY1=value1\nKEY2=value2');
});
});
describe('useCodexLensGpu', () => {
it('should fetch GPU detect and list data', async () => {
const mockDetect = { supported: true, has_cuda: true };
const mockList = {
devices: [
{ id: 0, name: 'GPU 0', type: 'cuda', driver: '12.0', memory: '8GB' },
],
selected_device_id: 0,
};
vi.mocked(api.fetchCodexLensGpuDetect).mockResolvedValue(mockDetect);
vi.mocked(api.fetchCodexLensGpuList).mockResolvedValue(mockList);
const { result } = renderHook(() => useCodexLensGpu(), { wrapper });
await waitFor(() => expect(result.current.isLoadingDetect).toBe(false));
await waitFor(() => expect(result.current.isLoadingList).toBe(false));
expect(api.fetchCodexLensGpuDetect).toHaveBeenCalledOnce();
expect(api.fetchCodexLensGpuList).toHaveBeenCalledOnce();
expect(result.current.supported).toBe(true);
expect(result.current.devices).toHaveLength(1);
expect(result.current.selectedDeviceId).toBe(0);
});
});
describe('useUpdateCodexLensConfig', () => {
it('should update config and invalidate queries', async () => {
vi.mocked(api.updateCodexLensConfig).mockResolvedValue({
success: true,
message: 'Config updated',
});
const { result } = renderHook(() => useUpdateCodexLensConfig(), { wrapper });
const updateResult = await result.current.updateConfig({
index_dir: '~/.codexlens/indexes',
api_max_workers: 8,
api_batch_size: 16,
});
expect(api.updateCodexLensConfig).toHaveBeenCalledWith({
index_dir: '~/.codexlens/indexes',
api_max_workers: 8,
api_batch_size: 16,
});
expect(updateResult.success).toBe(true);
expect(updateResult.message).toBe('Config updated');
});
});
describe('useBootstrapCodexLens', () => {
it('should bootstrap CodexLens and invalidate queries', async () => {
vi.mocked(api.bootstrapCodexLens).mockResolvedValue({
success: true,
version: '1.0.0',
});
const { result } = renderHook(() => useBootstrapCodexLens(), { wrapper });
const bootstrapResult = await result.current.bootstrap();
expect(api.bootstrapCodexLens).toHaveBeenCalledOnce();
expect(bootstrapResult.success).toBe(true);
expect(bootstrapResult.version).toBe('1.0.0');
});
});
describe('useUninstallCodexLens', () => {
it('should uninstall CodexLens and invalidate queries', async () => {
vi.mocked(api.uninstallCodexLens).mockResolvedValue({
success: true,
message: 'CodexLens uninstalled',
});
const { result } = renderHook(() => useUninstallCodexLens(), { wrapper });
const uninstallResult = await result.current.uninstall();
expect(api.uninstallCodexLens).toHaveBeenCalledOnce();
expect(uninstallResult.success).toBe(true);
});
});
describe('useDownloadModel', () => {
it('should download model by profile', async () => {
vi.mocked(api.downloadCodexLensModel).mockResolvedValue({
success: true,
message: 'Model downloaded',
});
const { result } = renderHook(() => useDownloadModel(), { wrapper });
const downloadResult = await result.current.downloadModel('model1');
expect(api.downloadCodexLensModel).toHaveBeenCalledWith('model1');
expect(downloadResult.success).toBe(true);
});
it('should download custom model', async () => {
vi.mocked(api.downloadCodexLensCustomModel).mockResolvedValue({
success: true,
message: 'Custom model downloaded',
});
const { result } = renderHook(() => useDownloadModel(), { wrapper });
const downloadResult = await result.current.downloadCustomModel('custom/model', 'embedding');
expect(api.downloadCodexLensCustomModel).toHaveBeenCalledWith('custom/model', 'embedding');
expect(downloadResult.success).toBe(true);
});
});
describe('useDeleteModel', () => {
it('should delete model by profile', async () => {
vi.mocked(api.deleteCodexLensModel).mockResolvedValue({
success: true,
message: 'Model deleted',
});
const { result } = renderHook(() => useDeleteModel(), { wrapper });
const deleteResult = await result.current.deleteModel('model1');
expect(api.deleteCodexLensModel).toHaveBeenCalledWith('model1');
expect(deleteResult.success).toBe(true);
});
it('should delete model by path', async () => {
vi.mocked(api.deleteCodexLensModelByPath).mockResolvedValue({
success: true,
message: 'Model deleted',
});
const { result } = renderHook(() => useDeleteModel(), { wrapper });
const deleteResult = await result.current.deleteModelByPath('/path/to/model');
expect(api.deleteCodexLensModelByPath).toHaveBeenCalledWith('/path/to/model');
expect(deleteResult.success).toBe(true);
});
});
describe('useUpdateCodexLensEnv', () => {
it('should update environment variables', async () => {
vi.mocked(api.updateCodexLensEnv).mockResolvedValue({
success: true,
env: { KEY1: 'newvalue' },
settings: {},
raw: 'KEY1=newvalue',
});
const { result } = renderHook(() => useUpdateCodexLensEnv(), { wrapper });
const updateResult = await result.current.updateEnv({
raw: 'KEY1=newvalue',
});
expect(api.updateCodexLensEnv).toHaveBeenCalledWith({ raw: 'KEY1=newvalue' });
expect(updateResult.success).toBe(true);
});
});
describe('useSelectGpu', () => {
it('should select GPU', async () => {
vi.mocked(api.selectCodexLensGpu).mockResolvedValue({
success: true,
message: 'GPU selected',
});
const { result } = renderHook(() => useSelectGpu(), { wrapper });
const selectResult = await result.current.selectGpu(0);
expect(api.selectCodexLensGpu).toHaveBeenCalledWith(0);
expect(selectResult.success).toBe(true);
});
it('should reset GPU', async () => {
vi.mocked(api.resetCodexLensGpu).mockResolvedValue({
success: true,
message: 'GPU reset',
});
const { result } = renderHook(() => useSelectGpu(), { wrapper });
const resetResult = await result.current.resetGpu();
expect(api.resetCodexLensGpu).toHaveBeenCalledOnce();
expect(resetResult.success).toBe(true);
});
});
describe('query refetch', () => {
it('should refetch dashboard data', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(1);
await result.current.refetch();
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,762 @@
// ========================================
// useCodexLens Hook
// ========================================
// TanStack Query hooks for CodexLens management
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchCodexLensDashboardInit,
fetchCodexLensStatus,
fetchCodexLensWorkspaceStatus,
fetchCodexLensConfig,
updateCodexLensConfig,
bootstrapCodexLens,
uninstallCodexLens,
fetchCodexLensModels,
fetchCodexLensModelInfo,
downloadCodexLensModel,
downloadCodexLensCustomModel,
deleteCodexLensModel,
deleteCodexLensModelByPath,
fetchCodexLensEnv,
updateCodexLensEnv,
fetchCodexLensGpuDetect,
fetchCodexLensGpuList,
selectCodexLensGpu,
resetCodexLensGpu,
fetchCodexLensIgnorePatterns,
updateCodexLensIgnorePatterns,
type CodexLensDashboardInitResponse,
type CodexLensVenvStatus,
type CodexLensConfig,
type CodexLensModelsResponse,
type CodexLensModelInfoResponse,
type CodexLensEnvResponse,
type CodexLensUpdateEnvResponse,
type CodexLensGpuDetectResponse,
type CodexLensGpuListResponse,
type CodexLensIgnorePatternsResponse,
type CodexLensUpdateEnvRequest,
type CodexLensUpdateIgnorePatternsRequest,
type CodexLensWorkspaceStatus,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// Query key factory
export const codexLensKeys = {
all: ['codexLens'] as const,
dashboard: () => [...codexLensKeys.all, 'dashboard'] as const,
status: () => [...codexLensKeys.all, 'status'] as const,
workspace: (path?: string) => [...codexLensKeys.all, 'workspace', path] as const,
config: () => [...codexLensKeys.all, 'config'] as const,
models: () => [...codexLensKeys.all, 'models'] as const,
modelInfo: (profile: string) => [...codexLensKeys.models(), 'info', profile] as const,
env: () => [...codexLensKeys.all, 'env'] as const,
gpu: () => [...codexLensKeys.all, 'gpu'] as const,
gpuList: () => [...codexLensKeys.gpu(), 'list'] as const,
gpuDetect: () => [...codexLensKeys.gpu(), 'detect'] as const,
ignorePatterns: () => [...codexLensKeys.all, 'ignorePatterns'] as const,
};
// Default stale times
const STALE_TIME_SHORT = 30 * 1000; // 30 seconds for frequently changing data
const STALE_TIME_MEDIUM = 2 * 60 * 1000; // 2 minutes for moderately changing data
const STALE_TIME_LONG = 10 * 60 * 1000; // 10 minutes for rarely changing data
// ========== Query Hooks ==========
export interface UseCodexLensDashboardOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensDashboardReturn {
data: CodexLensDashboardInitResponse | undefined;
installed: boolean;
status: CodexLensVenvStatus | undefined;
config: CodexLensConfig | undefined;
semantic: { available: boolean } | undefined;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens dashboard initialization data
*/
export function useCodexLensDashboard(options: UseCodexLensDashboardOptions = {}): UseCodexLensDashboardReturn {
const { enabled = true, staleTime = STALE_TIME_SHORT } = options;
const query = useQuery({
queryKey: codexLensKeys.dashboard(),
queryFn: fetchCodexLensDashboardInit,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
installed: query.data?.installed ?? false,
status: query.data?.status,
config: query.data?.config,
semantic: query.data?.semantic,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
};
}
export interface UseCodexLensStatusOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensStatusReturn {
status: CodexLensVenvStatus | undefined;
ready: boolean;
installed: boolean;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens venv status
*/
export function useCodexLensStatus(options: UseCodexLensStatusOptions = {}): UseCodexLensStatusReturn {
const { enabled = true, staleTime = STALE_TIME_SHORT } = options;
const query = useQuery({
queryKey: codexLensKeys.status(),
queryFn: fetchCodexLensStatus,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
status: query.data,
ready: query.data?.ready ?? false,
installed: query.data?.installed ?? false,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensWorkspaceStatusOptions {
projectPath?: string;
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensWorkspaceStatusReturn {
data: CodexLensWorkspaceStatus | undefined;
hasIndex: boolean;
ftsPercent: number;
vectorPercent: number;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens workspace index status
*/
export function useCodexLensWorkspaceStatus(options: UseCodexLensWorkspaceStatusOptions = {}): UseCodexLensWorkspaceStatusReturn {
const { projectPath, enabled = true, staleTime = STALE_TIME_SHORT } = options;
const projectPathFromStore = useWorkflowStore(selectProjectPath);
const actualProjectPath = projectPath ?? projectPathFromStore;
const queryEnabled = enabled && !!actualProjectPath;
const query = useQuery({
queryKey: codexLensKeys.workspace(actualProjectPath),
queryFn: () => fetchCodexLensWorkspaceStatus(actualProjectPath),
staleTime,
enabled: queryEnabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
hasIndex: query.data?.hasIndex ?? false,
ftsPercent: query.data?.fts.percent ?? 0,
vectorPercent: query.data?.vector.percent ?? 0,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensConfigOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensConfigReturn {
config: CodexLensConfig | undefined;
indexDir: string;
indexCount: number;
apiMaxWorkers: number;
apiBatchSize: number;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens configuration
*/
export function useCodexLensConfig(options: UseCodexLensConfigOptions = {}): UseCodexLensConfigReturn {
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
const query = useQuery({
queryKey: codexLensKeys.config(),
queryFn: fetchCodexLensConfig,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
config: query.data,
indexDir: query.data?.index_dir ?? '~/.codexlens/indexes',
indexCount: query.data?.index_count ?? 0,
apiMaxWorkers: query.data?.api_max_workers ?? 4,
apiBatchSize: query.data?.api_batch_size ?? 8,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensModelsOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensModelsReturn {
models: CodexLensModelsResponse['models'] | undefined;
embeddingModels: CodexLensModelsResponse['models'] | undefined;
rerankerModels: CodexLensModelsResponse['models'] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens models list
*/
export function useCodexLensModels(options: UseCodexLensModelsOptions = {}): UseCodexLensModelsReturn {
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
const query = useQuery({
queryKey: codexLensKeys.models(),
queryFn: fetchCodexLensModels,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
const models = query.data?.models ?? [];
const embeddingModels = models?.filter(m => m.type === 'embedding');
const rerankerModels = models?.filter(m => m.type === 'reranker');
return {
models,
embeddingModels,
rerankerModels,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensModelInfoOptions {
profile: string;
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensModelInfoReturn {
info: CodexLensModelInfoResponse['info'] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens model info by profile
*/
export function useCodexLensModelInfo(options: UseCodexLensModelInfoOptions): UseCodexLensModelInfoReturn {
const { profile, enabled = true, staleTime = STALE_TIME_LONG } = options;
const queryEnabled = enabled && !!profile;
const query = useQuery({
queryKey: codexLensKeys.modelInfo(profile),
queryFn: () => fetchCodexLensModelInfo(profile),
staleTime,
enabled: queryEnabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
info: query.data?.info,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensEnvOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensEnvReturn {
data: CodexLensEnvResponse | undefined;
env: Record<string, string> | undefined;
settings: Record<string, string> | undefined;
raw: string | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens environment variables
*/
export function useCodexLensEnv(options: UseCodexLensEnvOptions = {}): UseCodexLensEnvReturn {
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
const query = useQuery({
queryKey: codexLensKeys.env(),
queryFn: fetchCodexLensEnv,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
env: query.data?.env,
settings: query.data?.settings,
raw: query.data?.raw,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensGpuOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensGpuReturn {
detectData: CodexLensGpuDetectResponse | undefined;
listData: CodexLensGpuListResponse | undefined;
supported: boolean;
devices: CodexLensGpuListResponse['devices'] | undefined;
selectedDeviceId: string | number | undefined;
isLoadingDetect: boolean;
isLoadingList: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens GPU information
* Combines both detect and list queries
*/
export function useCodexLensGpu(options: UseCodexLensGpuOptions = {}): UseCodexLensGpuReturn {
const { enabled = true, staleTime = STALE_TIME_LONG } = options;
const detectQuery = useQuery({
queryKey: codexLensKeys.gpuDetect(),
queryFn: fetchCodexLensGpuDetect,
staleTime,
enabled,
retry: 2,
});
const listQuery = useQuery({
queryKey: codexLensKeys.gpuList(),
queryFn: fetchCodexLensGpuList,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await Promise.all([detectQuery.refetch(), listQuery.refetch()]);
};
return {
detectData: detectQuery.data,
listData: listQuery.data,
supported: detectQuery.data?.supported ?? false,
devices: listQuery.data?.devices,
selectedDeviceId: listQuery.data?.selected_device_id,
isLoadingDetect: detectQuery.isLoading,
isLoadingList: listQuery.isLoading,
error: detectQuery.error || listQuery.error,
refetch,
};
}
export interface UseCodexLensIgnorePatternsOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensIgnorePatternsReturn {
data: CodexLensIgnorePatternsResponse | undefined;
patterns: string[] | undefined;
extensionFilters: string[] | undefined;
defaults: CodexLensIgnorePatternsResponse['defaults'] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching CodexLens ignore patterns
*/
export function useCodexLensIgnorePatterns(options: UseCodexLensIgnorePatternsOptions = {}): UseCodexLensIgnorePatternsReturn {
const { enabled = true, staleTime = STALE_TIME_LONG } = options;
const query = useQuery({
queryKey: codexLensKeys.ignorePatterns(),
queryFn: fetchCodexLensIgnorePatterns,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
patterns: query.data?.patterns,
extensionFilters: query.data?.extensionFilters,
defaults: query.data?.defaults,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
// ========== Mutation Hooks ==========
export interface UseUpdateCodexLensConfigReturn {
updateConfig: (config: { index_dir: string; api_max_workers?: number; api_batch_size?: number }) => Promise<{ success: boolean; message?: string }>;
isUpdating: boolean;
error: Error | null;
}
/**
* Hook for updating CodexLens configuration
*/
export function useUpdateCodexLensConfig(): UseUpdateCodexLensConfigReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateCodexLensConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.config() });
queryClient.invalidateQueries({ queryKey: codexLensKeys.dashboard() });
},
});
return {
updateConfig: mutation.mutateAsync,
isUpdating: mutation.isPending,
error: mutation.error,
};
}
export interface UseBootstrapCodexLensReturn {
bootstrap: () => Promise<{ success: boolean; message?: string; version?: string }>;
isBootstrapping: boolean;
error: Error | null;
}
/**
* Hook for bootstrapping/installing CodexLens
*/
export function useBootstrapCodexLens(): UseBootstrapCodexLensReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: bootstrapCodexLens,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.all });
},
});
return {
bootstrap: mutation.mutateAsync,
isBootstrapping: mutation.isPending,
error: mutation.error,
};
}
export interface UseUninstallCodexLensReturn {
uninstall: () => Promise<{ success: boolean; message?: string }>;
isUninstalling: boolean;
error: Error | null;
}
/**
* Hook for uninstalling CodexLens
*/
export function useUninstallCodexLens(): UseUninstallCodexLensReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: uninstallCodexLens,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.all });
},
});
return {
uninstall: mutation.mutateAsync,
isUninstalling: mutation.isPending,
error: mutation.error,
};
}
export interface UseDownloadModelReturn {
downloadModel: (profile: string) => Promise<{ success: boolean; message?: string }>;
downloadCustomModel: (modelName: string, modelType?: string) => Promise<{ success: boolean; message?: string }>;
isDownloading: boolean;
error: Error | null;
}
/**
* Hook for downloading CodexLens models
*/
export function useDownloadModel(): UseDownloadModelReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async ({ profile, modelName, modelType }: { profile?: string; modelName?: string; modelType?: string }) => {
if (profile) return downloadCodexLensModel(profile);
if (modelName) return downloadCodexLensCustomModel(modelName, modelType);
throw new Error('Either profile or modelName must be provided');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.models() });
},
});
return {
downloadModel: (profile) => mutation.mutateAsync({ profile }),
downloadCustomModel: (modelName, modelType) => mutation.mutateAsync({ modelName, modelType }),
isDownloading: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteModelReturn {
deleteModel: (profile: string) => Promise<{ success: boolean; message?: string }>;
deleteModelByPath: (cachePath: string) => Promise<{ success: boolean; message?: string }>;
isDeleting: boolean;
error: Error | null;
}
/**
* Hook for deleting CodexLens models
*/
export function useDeleteModel(): UseDeleteModelReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async ({ profile, cachePath }: { profile?: string; cachePath?: string }) => {
if (profile) return deleteCodexLensModel(profile);
if (cachePath) return deleteCodexLensModelByPath(cachePath);
throw new Error('Either profile or cachePath must be provided');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.models() });
},
});
return {
deleteModel: (profile) => mutation.mutateAsync({ profile }),
deleteModelByPath: (cachePath) => mutation.mutateAsync({ cachePath }),
isDeleting: mutation.isPending,
error: mutation.error,
};
}
export interface UseUpdateCodexLensEnvReturn {
updateEnv: (request: CodexLensUpdateEnvRequest) => Promise<CodexLensUpdateEnvResponse>;
isUpdating: boolean;
error: Error | null;
}
/**
* Hook for updating CodexLens environment variables
*/
export function useUpdateCodexLensEnv(): UseUpdateCodexLensEnvReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (request: CodexLensUpdateEnvRequest) => updateCodexLensEnv(request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.env() });
queryClient.invalidateQueries({ queryKey: codexLensKeys.dashboard() });
},
});
return {
updateEnv: mutation.mutateAsync,
isUpdating: mutation.isPending,
error: mutation.error,
};
}
export interface UseSelectGpuReturn {
selectGpu: (deviceId: string | number) => Promise<{ success: boolean; message?: string }>;
resetGpu: () => Promise<{ success: boolean; message?: string }>;
isSelecting: boolean;
isResetting: boolean;
error: Error | null;
}
/**
* Hook for selecting/resetting GPU for CodexLens
*/
export function useSelectGpu(): UseSelectGpuReturn {
const queryClient = useQueryClient();
const selectMutation = useMutation({
mutationFn: (deviceId: string | number) => selectCodexLensGpu(deviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.gpu() });
},
});
const resetMutation = useMutation({
mutationFn: () => resetCodexLensGpu(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.gpu() });
},
});
return {
selectGpu: selectMutation.mutateAsync,
resetGpu: resetMutation.mutateAsync,
isSelecting: selectMutation.isPending,
isResetting: resetMutation.isPending,
error: selectMutation.error || resetMutation.error,
};
}
export interface UseUpdateIgnorePatternsReturn {
updatePatterns: (request: CodexLensUpdateIgnorePatternsRequest) => Promise<CodexLensIgnorePatternsResponse>;
isUpdating: boolean;
error: Error | null;
}
/**
* Hook for updating CodexLens ignore patterns
*/
export function useUpdateIgnorePatterns(): UseUpdateIgnorePatternsReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateCodexLensIgnorePatterns,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.ignorePatterns() });
},
});
return {
updatePatterns: mutation.mutateAsync,
isUpdating: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all CodexLens mutations
*/
export function useCodexLensMutations() {
const updateConfig = useUpdateCodexLensConfig();
const bootstrap = useBootstrapCodexLens();
const uninstall = useUninstallCodexLens();
const download = useDownloadModel();
const deleteModel = useDeleteModel();
const updateEnv = useUpdateCodexLensEnv();
const gpu = useSelectGpu();
const updatePatterns = useUpdateIgnorePatterns();
return {
updateConfig: updateConfig.updateConfig,
isUpdatingConfig: updateConfig.isUpdating,
bootstrap: bootstrap.bootstrap,
isBootstrapping: bootstrap.isBootstrapping,
uninstall: uninstall.uninstall,
isUninstalling: uninstall.isUninstalling,
downloadModel: download.downloadModel,
downloadCustomModel: download.downloadCustomModel,
isDownloading: download.isDownloading,
deleteModel: deleteModel.deleteModel,
deleteModelByPath: deleteModel.deleteModelByPath,
isDeleting: deleteModel.isDeleting,
updateEnv: updateEnv.updateEnv,
isUpdatingEnv: updateEnv.isUpdating,
selectGpu: gpu.selectGpu,
resetGpu: gpu.resetGpu,
isSelectingGpu: gpu.isSelecting || gpu.isResetting,
updatePatterns: updatePatterns.updatePatterns,
isUpdatingPatterns: updatePatterns.isUpdating,
isMutating:
updateConfig.isUpdating ||
bootstrap.isBootstrapping ||
uninstall.isUninstalling ||
download.isDownloading ||
deleteModel.isDeleting ||
updateEnv.isUpdating ||
gpu.isSelecting ||
gpu.isResetting ||
updatePatterns.isUpdating,
};
}

View File

@@ -52,13 +52,12 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath); const projectPath = useWorkflowStore(selectProjectPath);
const queryEnabled = enabled && !!projectPath;
const query = useQuery({ const query = useQuery({
queryKey: commandsKeys.list(filter), queryKey: commandsKeys.list(filter),
queryFn: () => fetchCommands(projectPath), queryFn: () => fetchCommands(projectPath),
staleTime, staleTime,
enabled: queryEnabled, enabled: enabled, // Remove projectPath requirement
retry: 2, retry: 2,
}); });

View File

@@ -15,8 +15,10 @@ import {
deactivateQueue, deactivateQueue,
deleteQueue as deleteQueueApi, deleteQueue as deleteQueueApi,
mergeQueues as mergeQueuesApi, mergeQueues as mergeQueuesApi,
splitQueue as splitQueueApi,
fetchDiscoveries, fetchDiscoveries,
fetchDiscoveryFindings, fetchDiscoveryFindings,
exportDiscoveryFindingsAsIssues,
type Issue, type Issue,
type IssueQueue, type IssueQueue,
type IssuesResponse, type IssuesResponse,
@@ -306,10 +308,12 @@ export interface UseQueueMutationsReturn {
deactivateQueue: () => Promise<void>; deactivateQueue: () => Promise<void>;
deleteQueue: (queueId: string) => Promise<void>; deleteQueue: (queueId: string) => Promise<void>;
mergeQueues: (sourceId: string, targetId: string) => Promise<void>; mergeQueues: (sourceId: string, targetId: string) => Promise<void>;
splitQueue: (sourceQueueId: string, itemIds: string[]) => Promise<void>;
isActivating: boolean; isActivating: boolean;
isDeactivating: boolean; isDeactivating: boolean;
isDeleting: boolean; isDeleting: boolean;
isMerging: boolean; isMerging: boolean;
isSplitting: boolean;
isMutating: boolean; isMutating: boolean;
} }
@@ -346,16 +350,26 @@ export function useQueueMutations(): UseQueueMutationsReturn {
}, },
}); });
const splitMutation = useMutation({
mutationFn: ({ sourceQueueId, itemIds }: { sourceQueueId: string; itemIds: string[] }) =>
splitQueueApi(sourceQueueId, itemIds, projectPath),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
},
});
return { return {
activateQueue: activateMutation.mutateAsync, activateQueue: activateMutation.mutateAsync,
deactivateQueue: deactivateMutation.mutateAsync, deactivateQueue: deactivateMutation.mutateAsync,
deleteQueue: deleteMutation.mutateAsync, deleteQueue: deleteMutation.mutateAsync,
mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }), mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }),
splitQueue: (sourceQueueId, itemIds) => splitMutation.mutateAsync({ sourceQueueId, itemIds }),
isActivating: activateMutation.isPending, isActivating: activateMutation.isPending,
isDeactivating: deactivateMutation.isPending, isDeactivating: deactivateMutation.isPending,
isDeleting: deleteMutation.isPending, isDeleting: deleteMutation.isPending,
isMerging: mergeMutation.isPending, isMerging: mergeMutation.isPending,
isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending, isSplitting: splitMutation.isPending,
isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending || splitMutation.isPending,
}; };
} }
@@ -365,6 +379,8 @@ export interface FindingFilters {
severity?: 'critical' | 'high' | 'medium' | 'low'; severity?: 'critical' | 'high' | 'medium' | 'low';
type?: string; type?: string;
search?: string; search?: string;
exported?: boolean;
hasIssue?: boolean;
} }
export interface UseIssueDiscoveryReturn { export interface UseIssueDiscoveryReturn {
@@ -380,6 +396,8 @@ export interface UseIssueDiscoveryReturn {
selectSession: (sessionId: string) => void; selectSession: (sessionId: string) => void;
refetchSessions: () => void; refetchSessions: () => void;
exportFindings: () => void; exportFindings: () => void;
exportSelectedFindings: (findingIds: string[]) => Promise<{ success: boolean; message?: string; exported?: number }>;
isExporting: boolean;
} }
export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIssueDiscoveryReturn { export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIssueDiscoveryReturn {
@@ -388,6 +406,7 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
const projectPath = useWorkflowStore(selectProjectPath); const projectPath = useWorkflowStore(selectProjectPath);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null); const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [filters, setFilters] = useState<FindingFilters>({}); const [filters, setFilters] = useState<FindingFilters>({});
const [isExporting, setIsExporting] = useState(false);
const sessionsQuery = useQuery({ const sessionsQuery = useQuery({
queryKey: workspaceQueryKeys.discoveries(projectPath), queryKey: workspaceQueryKeys.discoveries(projectPath),
@@ -426,6 +445,14 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
f.description.toLowerCase().includes(searchLower) f.description.toLowerCase().includes(searchLower)
); );
} }
// Filter by exported status
if (filters.exported !== undefined) {
findings = findings.filter(f => f.exported === filters.exported);
}
// Filter by hasIssue (has associated issue_id)
if (filters.hasIssue !== undefined) {
findings = findings.filter(f => !!f.issue_id === filters.hasIssue);
}
return findings; return findings;
}, [findingsQuery.data, filters]); }, [findingsQuery.data, filters]);
@@ -449,6 +476,26 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const exportSelectedFindings = async (findingIds: string[]) => {
if (!activeSessionId) return { success: false, message: 'No active session' };
setIsExporting(true);
try {
const result = await exportDiscoveryFindingsAsIssues(
activeSessionId,
{ findingIds },
projectPath
);
// Invalidate queries to refresh findings with updated exported status
await queryClient.invalidateQueries({ queryKey: ['discoveryFindings', activeSessionId, projectPath] });
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issues(projectPath) });
return result;
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : 'Export failed' };
} finally {
setIsExporting(false);
}
};
return { return {
sessions: sessionsQuery.data ?? [], sessions: sessionsQuery.data ?? [],
activeSession, activeSession,
@@ -464,5 +511,7 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
sessionsQuery.refetch(); sessionsQuery.refetch();
}, },
exportFindings, exportFindings,
exportSelectedFindings,
isExporting,
}; };
} }

View File

@@ -58,14 +58,11 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath); const projectPath = useWorkflowStore(selectProjectPath);
// Only enable query when projectPath is available
const queryEnabled = enabled && !!projectPath;
const query = useQuery({ const query = useQuery({
queryKey: workspaceQueryKeys.skillsList(projectPath), queryKey: workspaceQueryKeys.skillsList(projectPath),
queryFn: () => fetchSkills(projectPath), queryFn: () => fetchSkills(projectPath),
staleTime, staleTime,
enabled: queryEnabled, enabled: enabled, // Remove projectPath requirement - API works without it
retry: 2, retry: 2,
}); });

View File

@@ -146,13 +146,16 @@ async function fetchApi<T>(
status: response.status, status: response.status,
}; };
try { // Only try to parse JSON if the content type indicates JSON
const body = await response.json(); const contentType = response.headers.get('content-type');
if (body.message) error.message = body.message; if (contentType && contentType.includes('application/json')) {
if (body.code) error.code = body.code; try {
} catch (parseError) { const body = await response.json();
// Log parse errors instead of silently ignoring if (body.message) error.message = body.message;
console.warn('[API] Failed to parse error response:', parseError); if (body.code) error.code = body.code;
} catch (parseError) {
// Silently ignore JSON parse errors for non-JSON responses
}
} }
throw error; throw error;
@@ -599,12 +602,26 @@ export interface Issue {
assignee?: string; assignee?: string;
} }
export interface QueueItem {
item_id: string;
issue_id: string;
solution_id: string;
task_id?: string;
status: 'pending' | 'ready' | 'executing' | 'completed' | 'failed' | 'blocked';
execution_order: number;
execution_group: string;
depends_on: string[];
semantic_priority: number;
files_touched?: string[];
task_count?: number;
}
export interface IssueQueue { export interface IssueQueue {
tasks: string[]; tasks: string[];
solutions: string[]; solutions: string[];
conflicts: string[]; conflicts: string[];
execution_groups: string[]; execution_groups: string[];
grouped_items: Record<string, string[]>; grouped_items: Record<string, QueueItem[]>;
} }
export interface IssuesResponse { export interface IssuesResponse {
@@ -683,6 +700,37 @@ export async function deleteIssue(issueId: string): Promise<void> {
}); });
} }
/**
* Pull issues from GitHub
*/
export interface GitHubPullOptions {
state?: 'open' | 'closed' | 'all';
limit?: number;
labels?: string;
downloadImages?: boolean;
}
export interface GitHubPullResponse {
imported: number;
updated: number;
skipped: number;
images_downloaded: number;
total: number;
}
export async function pullIssuesFromGitHub(options: GitHubPullOptions = {}): Promise<GitHubPullResponse> {
const params = new URLSearchParams();
if (options.state) params.set('state', options.state);
if (options.limit) params.set('limit', String(options.limit));
if (options.labels) params.set('labels', options.labels);
if (options.downloadImages) params.set('downloadImages', 'true');
const url = `/api/issues/pull${params.toString() ? '?' + params.toString() : ''}`;
return fetchApi<GitHubPullResponse>(url, {
method: 'POST',
});
}
/** /**
* Activate a queue * Activate a queue
*/ */
@@ -720,6 +768,16 @@ export async function mergeQueues(sourceId: string, targetId: string, projectPat
}); });
} }
/**
* Split queue - split items from source queue into a new queue
*/
export async function splitQueue(sourceQueueId: string, itemIds: string[], projectPath: string): Promise<void> {
return fetchApi<void>(`/api/queue/split?path=${encodeURIComponent(projectPath)}`, {
method: 'POST',
body: JSON.stringify({ sourceQueueId, itemIds }),
});
}
// ========== Discovery API ========== // ========== Discovery API ==========
export interface DiscoverySession { export interface DiscoverySession {
@@ -743,14 +801,42 @@ export interface Finding {
line?: number; line?: number;
code_snippet?: string; code_snippet?: string;
created_at: string; created_at: string;
issue_id?: string; // Associated issue ID if exported
exported?: boolean; // Whether this finding has been exported as an issue
} }
export async function fetchDiscoveries(projectPath?: string): Promise<DiscoverySession[]> { export async function fetchDiscoveries(projectPath?: string): Promise<DiscoverySession[]> {
const url = projectPath const url = projectPath
? `/api/discoveries?path=${encodeURIComponent(projectPath)}` ? `/api/discoveries?path=${encodeURIComponent(projectPath)}`
: '/api/discoveries'; : '/api/discoveries';
const data = await fetchApi<{ sessions?: DiscoverySession[] }>(url); const data = await fetchApi<{ discoveries?: any[]; sessions?: DiscoverySession[] }>(url);
return data.sessions ?? [];
// Backend returns 'discoveries' with different schema, transform to frontend format
const rawDiscoveries = data.discoveries ?? data.sessions ?? [];
// Map backend schema to frontend DiscoverySession interface
return rawDiscoveries.map((d: any) => {
// Map phase to status
let status: 'running' | 'completed' | 'failed' = 'running';
if (d.phase === 'complete' || d.phase === 'completed') {
status = 'completed';
} else if (d.phase === 'failed') {
status = 'failed';
}
// Extract progress percentage from nested progress object
const progress = d.progress?.perspective_analysis?.percent_complete ?? 0;
return {
id: d.discovery_id || d.id,
name: d.target_pattern || d.discovery_id || d.name || 'Discovery',
status,
progress,
findings_count: d.total_findings ?? d.findings_count ?? 0,
created_at: d.created_at,
completed_at: d.completed_at
};
});
} }
export async function fetchDiscoveryDetail( export async function fetchDiscoveryDetail(
@@ -774,6 +860,27 @@ export async function fetchDiscoveryFindings(
return data.findings ?? []; return data.findings ?? [];
} }
/**
* Export findings as issues
* @param sessionId - Discovery session ID
* @param findingIds - Array of finding IDs to export
* @param exportAll - Export all findings if true
* @param projectPath - Optional project path
*/
export async function exportDiscoveryFindingsAsIssues(
sessionId: string,
{ findingIds, exportAll }: { findingIds?: string[]; exportAll?: boolean },
projectPath?: string
): Promise<{ success: boolean; message?: string; exported?: number }> {
const url = projectPath
? `/api/discoveries/${encodeURIComponent(sessionId)}/export?path=${encodeURIComponent(projectPath)}`
: `/api/discoveries/${encodeURIComponent(sessionId)}/export`;
return fetchApi<{ success: boolean; message?: string; exported?: number }>(url, {
method: 'POST',
body: JSON.stringify({ finding_ids: findingIds, export_all: exportAll }),
});
}
// ========== Skills API ========== // ========== Skills API ==========
export interface Skill { export interface Skill {
@@ -796,10 +903,30 @@ 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> {
const url = projectPath ? `/api/skills?path=${encodeURIComponent(projectPath)}` : '/api/skills'; // Try with project path first, fall back to global on 403/404
const data = await fetchApi<{ skills?: Skill[] }>(url); if (projectPath) {
try {
const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url);
const allSkills = [...(data.projectSkills ?? []), ...(data.userSkills ?? [])];
return {
skills: data.skills ?? allSkills,
};
} catch (error: unknown) {
const apiError = error as ApiError;
if (apiError.status === 403 || apiError.status === 404) {
// Fall back to global skills list
console.warn('[fetchSkills] 403/404 for project path, falling back to global skills');
} else {
throw error;
}
}
}
// Fallback: fetch global skills
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/skills');
const allSkills = [...(data.projectSkills ?? []), ...(data.userSkills ?? [])];
return { return {
skills: data.skills ?? [], skills: data.skills ?? allSkills,
}; };
} }
@@ -834,10 +961,30 @@ export interface CommandsResponse {
* @param projectPath - Optional project path to filter data by workspace * @param projectPath - Optional project path to filter data by workspace
*/ */
export async function fetchCommands(projectPath?: string): Promise<CommandsResponse> { export async function fetchCommands(projectPath?: string): Promise<CommandsResponse> {
const url = projectPath ? `/api/commands?path=${encodeURIComponent(projectPath)}` : '/api/commands'; // Try with project path first, fall back to global on 403/404
const data = await fetchApi<{ commands?: Command[] }>(url); if (projectPath) {
try {
const url = `/api/commands?path=${encodeURIComponent(projectPath)}`;
const data = await fetchApi<{ commands?: Command[]; projectCommands?: Command[]; userCommands?: Command[] }>(url);
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
return {
commands: data.commands ?? allCommands,
};
} catch (error: unknown) {
const apiError = error as ApiError;
if (apiError.status === 403 || apiError.status === 404) {
// Fall back to global commands list
console.warn('[fetchCommands] 403/404 for project path, falling back to global commands');
} else {
throw error;
}
}
}
// Fallback: fetch global commands
const data = await fetchApi<{ commands?: Command[]; projectCommands?: Command[]; userCommands?: Command[] }>('/api/commands');
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
return { return {
commands: data.commands ?? [], commands: data.commands ?? allCommands,
}; };
} }
@@ -864,12 +1011,36 @@ export interface MemoryResponse {
* @param projectPath - Optional project path to filter data by workspace * @param projectPath - Optional project path to filter data by workspace
*/ */
export async function fetchMemories(projectPath?: string): Promise<MemoryResponse> { export async function fetchMemories(projectPath?: string): Promise<MemoryResponse> {
const url = projectPath ? `/api/memory?path=${encodeURIComponent(projectPath)}` : '/api/memory'; // Try with project path first, fall back to global on 403/404
if (projectPath) {
try {
const url = `/api/memory?path=${encodeURIComponent(projectPath)}`;
const data = await fetchApi<{
memories?: CoreMemory[];
totalSize?: number;
claudeMdCount?: number;
}>(url);
return {
memories: data.memories ?? [],
totalSize: data.totalSize ?? 0,
claudeMdCount: data.claudeMdCount ?? 0,
};
} catch (error: unknown) {
const apiError = error as ApiError;
if (apiError.status === 403 || apiError.status === 404) {
// Fall back to global memories list
console.warn('[fetchMemories] 403/404 for project path, falling back to global memories');
} else {
throw error;
}
}
}
// Fallback: fetch global memories
const data = await fetchApi<{ const data = await fetchApi<{
memories?: CoreMemory[]; memories?: CoreMemory[];
totalSize?: number; totalSize?: number;
claudeMdCount?: number; claudeMdCount?: number;
}>(url); }>('/api/memory');
return { return {
memories: data.memories ?? [], memories: data.memories ?? [],
totalSize: data.totalSize ?? 0, totalSize: data.totalSize ?? 0,
@@ -1027,6 +1198,65 @@ export interface SessionDetailContext {
tech_stack?: string[]; tech_stack?: string[];
conventions?: string[]; conventions?: string[];
}; };
// Extended context fields for context-package.json
context?: {
metadata?: {
task_description?: string;
session_id?: string;
complexity?: string;
keywords?: string[];
};
project_context?: {
tech_stack?: {
languages?: Array<{ name: string; file_count?: number }>;
frameworks?: string[];
libraries?: string[];
};
architecture_patterns?: string[];
};
assets?: {
documentation?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
source_code?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
tests?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
};
dependencies?: {
internal?: Array<{ from: string; type: string; to: string }>;
external?: Array<{ package: string; version?: string; usage?: string }>;
};
test_context?: {
frameworks?: {
backend?: { name?: string; plugins?: string[] };
frontend?: { name?: string };
};
existing_tests?: string[];
coverage_config?: Record<string, unknown>;
test_markers?: string[];
};
conflict_detection?: {
risk_level?: 'low' | 'medium' | 'high' | 'critical';
mitigation_strategy?: string;
risk_factors?: {
test_gaps?: string[];
existing_implementations?: string[];
};
affected_modules?: string[];
};
};
explorations?: {
manifest: {
task_description: string;
complexity?: string;
exploration_count: number;
};
data: Record<string, {
project_structure?: string[];
relevant_files?: string[];
patterns?: string[];
dependencies?: string[];
integration_points?: string[];
testing?: string[];
}>;
};
} }
export interface SessionDetailResponse { export interface SessionDetailResponse {
@@ -1136,6 +1366,47 @@ export async function deleteAllHistory(): Promise<void> {
}); });
} }
// ========== Task Status Update API ==========
/**
* Bulk update task status for multiple tasks
* @param sessionPath - Path to session directory
* @param taskIds - Array of task IDs to update
* @param newStatus - New status to set
*/
export async function bulkUpdateTaskStatus(
sessionPath: string,
taskIds: string[],
newStatus: TaskStatus
): Promise<{ success: boolean; updated: number; error?: string }> {
return fetchApi('/api/bulk-update-task-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionPath, taskIds, newStatus }),
});
}
/**
* Update single task status
* @param sessionPath - Path to session directory
* @param taskId - Task ID to update
* @param newStatus - New status to set
*/
export async function updateTaskStatus(
sessionPath: string,
taskId: string,
newStatus: TaskStatus
): Promise<{ success: boolean; error?: string }> {
return fetchApi('/api/update-task-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionPath, taskId, newStatus }),
});
}
// Task status type (matches TaskData.status)
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped';
/** /**
* Fetch CLI execution detail (conversation records) * Fetch CLI execution detail (conversation records)
*/ */
@@ -1728,10 +1999,30 @@ export async function installHookTemplate(templateId: string): Promise<Hook> {
* @param projectPath - Optional project path to filter data by workspace * @param projectPath - Optional project path to filter data by workspace
*/ */
export async function fetchRules(projectPath?: string): Promise<RulesResponse> { export async function fetchRules(projectPath?: string): Promise<RulesResponse> {
const url = projectPath ? `/api/rules?path=${encodeURIComponent(projectPath)}` : '/api/rules'; // Try with project path first, fall back to global on 403/404
const data = await fetchApi<{ rules?: Rule[] }>(url); if (projectPath) {
try {
const url = `/api/rules?path=${encodeURIComponent(projectPath)}`;
const data = await fetchApi<{ rules?: Rule[]; projectRules?: Rule[]; userRules?: Rule[] }>(url);
const allRules = [...(data.projectRules ?? []), ...(data.userRules ?? [])];
return {
rules: data.rules ?? allRules,
};
} catch (error: unknown) {
const apiError = error as ApiError;
if (apiError.status === 403 || apiError.status === 404) {
// Fall back to global rules list
console.warn('[fetchRules] 403/404 for project path, falling back to global rules');
} else {
throw error;
}
}
}
// Fallback: fetch global rules
const data = await fetchApi<{ rules?: Rule[]; projectRules?: Rule[]; userRules?: Rule[] }>('/api/rules');
const allRules = [...(data.projectRules ?? []), ...(data.userRules ?? [])];
return { return {
rules: data.rules ?? [], rules: data.rules ?? allRules,
}; };
} }
@@ -2091,3 +2382,428 @@ export async function fetchGraphImpact(request: GraphImpactRequest): Promise<Gra
return fetchApi<GraphImpactResponse>(`/api/graph/impact?${params.toString()}`); return fetchApi<GraphImpactResponse>(`/api/graph/impact?${params.toString()}`);
} }
// ========== CodexLens API ==========
/**
* CodexLens venv status response
*/
export interface CodexLensVenvStatus {
ready: boolean;
installed: boolean;
version?: string;
pythonVersion?: string;
venvPath?: string;
error?: string;
}
/**
* CodexLens status data
*/
export interface CodexLensStatusData {
projects_count?: number;
total_files?: number;
total_chunks?: number;
api_url?: string;
api_ready?: boolean;
[key: string]: unknown;
}
/**
* CodexLens configuration
*/
export interface CodexLensConfig {
index_dir: string;
index_count: number;
api_max_workers: number;
api_batch_size: number;
}
/**
* Semantic search status
*/
export interface CodexLensSemanticStatus {
available: boolean;
backend?: string;
model?: string;
hasEmbeddings?: boolean;
[key: string]: unknown;
}
/**
* Dashboard init response
*/
export interface CodexLensDashboardInitResponse {
installed: boolean;
status: CodexLensVenvStatus;
config: CodexLensConfig;
semantic: CodexLensSemanticStatus;
statusData?: CodexLensStatusData;
}
/**
* Workspace index status
*/
export interface CodexLensWorkspaceStatus {
success: boolean;
hasIndex: boolean;
path?: string;
fts: {
percent: number;
indexedFiles: number;
totalFiles: number;
};
vector: {
percent: number;
filesWithEmbeddings: number;
totalFiles: number;
totalChunks: number;
};
}
/**
* GPU device info
*/
export interface CodexLensGpuDevice {
name: string;
type: 'integrated' | 'discrete';
index: number;
device_id?: string;
memory?: {
total?: number;
free?: number;
};
}
/**
* GPU detect response
*/
export interface CodexLensGpuDetectResponse {
success: boolean;
supported: boolean;
platform: string;
deviceCount?: number;
devices?: CodexLensGpuDevice[];
error?: string;
}
/**
* GPU list response
*/
export interface CodexLensGpuListResponse {
success: boolean;
devices: CodexLensGpuDevice[];
selected_device_id?: string | number;
}
/**
* Model info
*/
export interface CodexLensModel {
profile: string;
name: string;
type: 'embedding' | 'reranker';
backend: string;
size?: string;
installed: boolean;
cache_path?: string;
}
/**
* Model list response
*/
export interface CodexLensModelsResponse {
success: boolean;
models: CodexLensModel[];
}
/**
* Model info response
*/
export interface CodexLensModelInfoResponse {
success: boolean;
profile: string;
info: {
name: string;
backend: string;
type: string;
size?: string;
path?: string;
[key: string]: unknown;
};
}
/**
* Download model response
*/
export interface CodexLensDownloadModelResponse {
success: boolean;
message?: string;
profile?: string;
progress?: number;
error?: string;
}
/**
* Delete model response
*/
export interface CodexLensDeleteModelResponse {
success: boolean;
message?: string;
error?: string;
}
/**
* Environment variables response
*/
export interface CodexLensEnvResponse {
success: boolean;
path?: string;
env: Record<string, string>;
raw?: string;
settings?: Record<string, string>;
}
/**
* Update environment request
*/
export interface CodexLensUpdateEnvRequest {
env: Record<string, string>;
}
/**
* Update environment response
*/
export interface CodexLensUpdateEnvResponse {
success: boolean;
message?: string;
path?: string;
settingsPath?: string;
}
/**
* Ignore patterns response
*/
export interface CodexLensIgnorePatternsResponse {
success: boolean;
patterns: string[];
extensionFilters: string[];
defaults: {
patterns: string[];
extensionFilters: string[];
};
}
/**
* Update ignore patterns request
*/
export interface CodexLensUpdateIgnorePatternsRequest {
patterns?: string[];
extensionFilters?: string[];
}
/**
* Bootstrap install response
*/
export interface CodexLensBootstrapResponse {
success: boolean;
message?: string;
version?: string;
error?: string;
}
/**
* Uninstall response
*/
export interface CodexLensUninstallResponse {
success: boolean;
message?: string;
error?: string;
}
/**
* Fetch CodexLens dashboard initialization data
*/
export async function fetchCodexLensDashboardInit(): Promise<CodexLensDashboardInitResponse> {
return fetchApi<CodexLensDashboardInitResponse>('/api/codexlens/dashboard-init');
}
/**
* Fetch CodexLens venv status
*/
export async function fetchCodexLensStatus(): Promise<CodexLensVenvStatus> {
return fetchApi<CodexLensVenvStatus>('/api/codexlens/status');
}
/**
* Fetch CodexLens workspace index status
*/
export async function fetchCodexLensWorkspaceStatus(projectPath: string): Promise<CodexLensWorkspaceStatus> {
const params = new URLSearchParams();
params.append('path', projectPath);
return fetchApi<CodexLensWorkspaceStatus>(`/api/codexlens/workspace-status?${params.toString()}`);
}
/**
* Fetch CodexLens configuration
*/
export async function fetchCodexLensConfig(): Promise<CodexLensConfig> {
return fetchApi<CodexLensConfig>('/api/codexlens/config');
}
/**
* Update CodexLens configuration
*/
export async function updateCodexLensConfig(config: {
index_dir: string;
api_max_workers?: number;
api_batch_size?: number;
}): Promise<{ success: boolean; message?: string; error?: string }> {
return fetchApi('/api/codexlens/config', {
method: 'POST',
body: JSON.stringify(config),
});
}
/**
* Bootstrap/install CodexLens
*/
export async function bootstrapCodexLens(): Promise<CodexLensBootstrapResponse> {
return fetchApi<CodexLensBootstrapResponse>('/api/codexlens/bootstrap', {
method: 'POST',
body: JSON.stringify({}),
});
}
/**
* Uninstall CodexLens
*/
export async function uninstallCodexLens(): Promise<CodexLensUninstallResponse> {
return fetchApi<CodexLensUninstallResponse>('/api/codexlens/uninstall', {
method: 'POST',
body: JSON.stringify({}),
});
}
/**
* Fetch CodexLens models list
*/
export async function fetchCodexLensModels(): Promise<CodexLensModelsResponse> {
return fetchApi<CodexLensModelsResponse>('/api/codexlens/models');
}
/**
* Fetch CodexLens model info by profile
*/
export async function fetchCodexLensModelInfo(profile: string): Promise<CodexLensModelInfoResponse> {
const params = new URLSearchParams();
params.append('profile', profile);
return fetchApi<CodexLensModelInfoResponse>(`/api/codexlens/models/info?${params.toString()}`);
}
/**
* Download CodexLens model by profile
*/
export async function downloadCodexLensModel(profile: string): Promise<CodexLensDownloadModelResponse> {
return fetchApi<CodexLensDownloadModelResponse>('/api/codexlens/models/download', {
method: 'POST',
body: JSON.stringify({ profile }),
});
}
/**
* Download custom CodexLens model from HuggingFace
*/
export async function downloadCodexLensCustomModel(modelName: string, modelType: string = 'embedding'): Promise<CodexLensDownloadModelResponse> {
return fetchApi<CodexLensDownloadModelResponse>('/api/codexlens/models/download-custom', {
method: 'POST',
body: JSON.stringify({ model_name: modelName, model_type: modelType }),
});
}
/**
* Delete CodexLens model by profile
*/
export async function deleteCodexLensModel(profile: string): Promise<CodexLensDeleteModelResponse> {
return fetchApi<CodexLensDeleteModelResponse>('/api/codexlens/models/delete', {
method: 'POST',
body: JSON.stringify({ profile }),
});
}
/**
* Delete CodexLens model by cache path
*/
export async function deleteCodexLensModelByPath(cachePath: string): Promise<CodexLensDeleteModelResponse> {
return fetchApi<CodexLensDeleteModelResponse>('/api/codexlens/models/delete-path', {
method: 'POST',
body: JSON.stringify({ cache_path: cachePath }),
});
}
/**
* Fetch CodexLens environment variables
*/
export async function fetchCodexLensEnv(): Promise<CodexLensEnvResponse> {
return fetchApi<CodexLensEnvResponse>('/api/codexlens/env');
}
/**
* Update CodexLens environment variables
*/
export async function updateCodexLensEnv(request: CodexLensUpdateEnvRequest): Promise<CodexLensUpdateEnvResponse> {
return fetchApi<CodexLensUpdateEnvResponse>('/api/codexlens/env', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Detect GPU support for CodexLens
*/
export async function fetchCodexLensGpuDetect(): Promise<CodexLensGpuDetectResponse> {
return fetchApi<CodexLensGpuDetectResponse>('/api/codexlens/gpu/detect');
}
/**
* Fetch available GPU devices
*/
export async function fetchCodexLensGpuList(): Promise<CodexLensGpuListResponse> {
return fetchApi<CodexLensGpuListResponse>('/api/codexlens/gpu/list');
}
/**
* Select GPU device for CodexLens
*/
export async function selectCodexLensGpu(deviceId: string | number): Promise<{ success: boolean; message?: string; error?: string }> {
return fetchApi('/api/codexlens/gpu/select', {
method: 'POST',
body: JSON.stringify({ device_id: deviceId }),
});
}
/**
* Reset GPU selection to auto-detection
*/
export async function resetCodexLensGpu(): Promise<{ success: boolean; message?: string; error?: string }> {
return fetchApi('/api/codexlens/gpu/reset', {
method: 'POST',
body: JSON.stringify({}),
});
}
/**
* Fetch CodexLens ignore patterns
*/
export async function fetchCodexLensIgnorePatterns(): Promise<CodexLensIgnorePatternsResponse> {
return fetchApi<CodexLensIgnorePatternsResponse>('/api/codexlens/ignore-patterns');
}
/**
* Update CodexLens ignore patterns
*/
export async function updateCodexLensIgnorePatterns(request: CodexLensUpdateIgnorePatternsRequest): Promise<CodexLensIgnorePatternsResponse> {
return fetchApi<CodexLensIgnorePatternsResponse>('/api/codexlens/ignore-patterns', {
method: 'POST',
body: JSON.stringify(request),
});
}

View File

@@ -66,6 +66,10 @@
"automation": "Automation" "automation": "Automation"
}, },
"templates": { "templates": {
"ccw-status-tracker": {
"name": "CCW Status Tracker",
"description": "Parse CCW status.json and display current/next command"
},
"ccw-notify": { "ccw-notify": {
"name": "CCW Dashboard Notify", "name": "CCW Dashboard Notify",
"description": "Send notifications to CCW dashboard when files are written" "description": "Send notifications to CCW dashboard when files are written"

View File

@@ -0,0 +1,178 @@
{
"title": "CodexLens",
"description": "Semantic code search engine",
"bootstrap": "Bootstrap",
"bootstrapping": "Bootstrapping...",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling...",
"confirmUninstall": "Are you sure you want to uninstall CodexLens? This action cannot be undone.",
"confirmUninstallTitle": "Confirm Uninstall",
"notInstalled": "CodexLens is not installed",
"comingSoon": "Coming Soon",
"tabs": {
"overview": "Overview",
"settings": "Settings",
"models": "Models",
"advanced": "Advanced"
},
"overview": {
"status": {
"installation": "Installation Status",
"ready": "Ready",
"notReady": "Not Ready",
"version": "Version",
"indexPath": "Index Path",
"indexCount": "Index Count"
},
"notInstalled": {
"title": "CodexLens Not Installed",
"message": "Please install CodexLens to use semantic code search features."
},
"actions": {
"title": "Quick Actions",
"ftsFull": "FTS Full",
"ftsFullDesc": "Rebuild full-text index",
"ftsIncremental": "FTS Incremental",
"ftsIncrementalDesc": "Incremental update full-text index",
"vectorFull": "Vector Full",
"vectorFullDesc": "Rebuild vector index",
"vectorIncremental": "Vector Incremental",
"vectorIncrementalDesc": "Incremental update vector index"
},
"venv": {
"title": "Python Virtual Environment Details",
"pythonVersion": "Python Version",
"venvPath": "Virtual Environment Path",
"lastCheck": "Last Check Time"
}
},
"settings": {
"currentCount": "Current Index Count",
"currentWorkers": "Current Workers",
"currentBatchSize": "Current Batch Size",
"configTitle": "Basic Configuration",
"indexDir": {
"label": "Index Directory",
"placeholder": "~/.codexlens/indexes",
"hint": "Directory path for storing code indexes"
},
"maxWorkers": {
"label": "Max Workers",
"hint": "API concurrent processing threads (1-32)"
},
"batchSize": {
"label": "Batch Size",
"hint": "Number of files processed per batch (1-64)"
},
"validation": {
"indexDirRequired": "Index directory is required",
"maxWorkersRange": "Workers must be between 1 and 32",
"batchSizeRange": "Batch size must be between 1 and 64"
},
"save": "Save",
"saving": "Saving...",
"reset": "Reset",
"saveSuccess": "Configuration saved",
"saveFailed": "Save failed",
"configUpdated": "Configuration updated successfully",
"saveError": "Error saving configuration",
"unknownError": "An unknown error occurred"
},
"gpu": {
"title": "GPU Settings",
"status": "GPU Status",
"enabled": "Enabled",
"available": "Available",
"unavailable": "Unavailable",
"supported": "Your system supports GPU acceleration",
"notSupported": "Your system does not support GPU acceleration",
"detect": "Detect",
"detectSuccess": "GPU detection completed",
"detectFailed": "GPU detection failed",
"detectComplete": "Detected {count} GPU devices",
"detectError": "Error detecting GPU",
"select": "Select",
"selected": "Selected",
"active": "Current",
"selectSuccess": "GPU selected",
"selectFailed": "GPU selection failed",
"gpuSelected": "GPU device enabled",
"selectError": "Error selecting GPU",
"reset": "Reset",
"resetSuccess": "GPU reset",
"resetFailed": "GPU reset failed",
"gpuReset": "GPU disabled, will use CPU",
"resetError": "Error resetting GPU",
"unknownError": "An unknown error occurred",
"noDevices": "No GPU devices detected",
"notAvailable": "GPU functionality not available",
"unknownDevice": "Unknown device",
"type": "Type",
"driver": "Driver Version",
"memory": "Memory"
},
"advanced": {
"warningTitle": "Sensitive Operations Warning",
"warningMessage": "Modifying environment variables may affect CodexLens operation. Ensure you understand each variable's purpose.",
"currentVars": "Current Environment Variables",
"settingsVars": "Settings Variables",
"customVars": "Custom Variables",
"envEditor": "Environment Variable Editor",
"envFile": "File",
"envContent": "Environment Variable Content",
"envPlaceholder": "# Comment lines start with #\nKEY=value\nANOTHER_KEY=\"another value\"",
"envHint": "One variable per line, format: KEY=value. Comment lines start with #",
"save": "Save",
"saving": "Saving...",
"reset": "Reset",
"saveSuccess": "Environment variables saved",
"saveFailed": "Save failed",
"envUpdated": "Environment variables updated, restart service to take effect",
"saveError": "Error saving environment variables",
"unknownError": "An unknown error occurred",
"validation": {
"invalidKeys": "Invalid variable names: {keys}"
},
"helpTitle": "Format Help",
"helpComment": "Comment lines start with #",
"helpFormat": "Variable format: KEY=value",
"helpQuotes": "Values with spaces should use quotes",
"helpRestart": "Restart service after changes to take effect"
},
"models": {
"title": "Model Management",
"searchPlaceholder": "Search models...",
"downloading": "Downloading...",
"status": {
"downloaded": "Downloaded",
"available": "Available"
},
"types": {
"embedding": "Embedding Models",
"reranker": "Reranker Models"
},
"filters": {
"label": "Filter",
"all": "All"
},
"actions": {
"download": "Download",
"delete": "Delete",
"cancel": "Cancel"
},
"custom": {
"title": "Custom Model",
"placeholder": "HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)",
"description": "Download custom models from HuggingFace. Ensure the model name is correct."
},
"deleteConfirm": "Are you sure you want to delete model {modelName}?",
"notInstalled": {
"title": "CodexLens Not Installed",
"description": "Please install CodexLens to use model management features."
},
"empty": {
"title": "No models found",
"description": "Try adjusting your search or filter criteria"
}
}
}

View File

@@ -23,6 +23,7 @@ import skills from './skills.json';
import cliManager from './cli-manager.json'; import cliManager from './cli-manager.json';
import cliMonitor from './cli-monitor.json'; import cliMonitor from './cli-monitor.json';
import mcpManager from './mcp-manager.json'; import mcpManager from './mcp-manager.json';
import codexlens from './codexlens.json';
import theme from './theme.json'; import theme from './theme.json';
import executionMonitor from './execution-monitor.json'; import executionMonitor from './execution-monitor.json';
import cliHooks from './cli-hooks.json'; import cliHooks from './cli-hooks.json';
@@ -77,9 +78,10 @@ export default {
...flattenMessages(reviewSession, 'reviewSession'), ...flattenMessages(reviewSession, 'reviewSession'),
...flattenMessages(sessionDetail, 'sessionDetail'), ...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'), ...flattenMessages(skills, 'skills'),
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys ...flattenMessages(cliManager, 'cli-manager'),
...flattenMessages(cliMonitor, 'cliMonitor'), ...flattenMessages(cliMonitor, 'cliMonitor'),
...flattenMessages(mcpManager, 'mcp'), ...flattenMessages(mcpManager, 'mcp'),
...flattenMessages(codexlens, 'codexlens'),
...flattenMessages(theme, 'theme'), ...flattenMessages(theme, 'theme'),
...flattenMessages(cliHooks, 'cliHooks'), ...flattenMessages(cliHooks, 'cliHooks'),
...flattenMessages(executionMonitor, 'executionMonitor'), ...flattenMessages(executionMonitor, 'executionMonitor'),

View File

@@ -97,6 +97,23 @@
"noSessions": "No sessions found", "noSessions": "No sessions found",
"noSessionsDescription": "Start a new discovery session to begin", "noSessionsDescription": "Start a new discovery session to begin",
"findingsDetail": "Findings Detail", "findingsDetail": "Findings Detail",
"selectSession": "Select a session to view findings",
"sessionId": "Session ID",
"name": "Name",
"status": "Status",
"createdAt": "Created At",
"completedAt": "Completed At",
"progress": "Progress",
"findingsCount": "Findings Count",
"export": "Export JSON",
"exportSelected": "Export Selected ({count})",
"exporting": "Exporting...",
"exportAsIssues": "Export as Issues",
"severityBreakdown": "Severity Breakdown",
"typeBreakdown": "Type Breakdown",
"tabFindings": "Findings",
"tabProgress": "Progress",
"tabInfo": "Session Info",
"stats": { "stats": {
"totalSessions": "Total Sessions", "totalSessions": "Total Sessions",
"completed": "Completed", "completed": "Completed",
@@ -129,8 +146,31 @@
"type": { "type": {
"all": "All Types" "all": "All Types"
}, },
"exportedStatus": {
"all": "All Export Status",
"exported": "Exported",
"notExported": "Not Exported"
},
"issueStatus": {
"all": "All Issue Status",
"hasIssue": "Has Issue",
"noIssue": "No Issue"
},
"noFindings": "No findings found", "noFindings": "No findings found",
"export": "Export" "noFindingsDescription": "No matching findings found",
"searchPlaceholder": "Search findings...",
"filterBySeverity": "Filter by severity",
"filterByType": "Filter by type",
"filterByExported": "Filter by export status",
"filterByIssue": "Filter by issue link",
"allSeverities": "All severities",
"allTypes": "All types",
"showingCount": "Showing {count} findings",
"exported": "Exported",
"hasIssue": "Linked",
"export": "Export",
"selectAll": "Select All",
"deselectAll": "Deselect All"
}, },
"tabs": { "tabs": {
"findings": "Findings", "findings": "Findings",

View File

@@ -16,6 +16,7 @@
"prompts": "Prompt History", "prompts": "Prompt History",
"settings": "Settings", "settings": "Settings",
"mcp": "MCP Servers", "mcp": "MCP Servers",
"codexlens": "CodexLens",
"endpoints": "CLI Endpoints", "endpoints": "CLI Endpoints",
"installations": "Installations", "installations": "Installations",
"help": "Help", "help": "Help",

View File

@@ -6,13 +6,25 @@
"tabs": { "tabs": {
"tasks": "Tasks", "tasks": "Tasks",
"context": "Context", "context": "Context",
"summary": "Summary" "summary": "Summary",
"implPlan": "IMPL Plan",
"conflict": "Conflict",
"review": "Review"
}, },
"tasks": { "tasks": {
"completed": "completed", "completed": "completed",
"inProgress": "in progress", "inProgress": "in progress",
"pending": "pending", "pending": "pending",
"blocked": "blocked", "blocked": "blocked",
"quickActions": {
"markAllPending": "All Pending",
"markAllInProgress": "All In Progress",
"markAllCompleted": "All Completed"
},
"statusUpdate": {
"success": "Task status updated successfully",
"error": "Failed to update task status"
},
"status": { "status": {
"pending": "Pending", "pending": "Pending",
"inProgress": "In Progress", "inProgress": "In Progress",
@@ -36,15 +48,114 @@
"empty": { "empty": {
"title": "No Context Available", "title": "No Context Available",
"message": "This session has no context information." "message": "This session has no context information."
},
"explorations": {
"title": "Explorations",
"angles": "angles",
"projectStructure": "Project Structure",
"relevantFiles": "Relevant Files",
"patterns": "Patterns",
"dependencies": "Dependencies",
"integrationPoints": "Integration Points",
"testing": "Testing"
},
"categories": {
"documentation": "Documentation",
"sourceCode": "Source Code",
"tests": "Tests"
},
"assets": {
"title": "Assets",
"noData": "No assets found",
"scope": "Scope",
"contains": "Contains"
},
"dependencies": {
"title": "Dependencies",
"internal": "Internal",
"external": "External",
"from": "From",
"to": "To",
"type": "Type"
},
"testContext": {
"title": "Test Context",
"tests": "tests",
"existingTests": "existing tests",
"markers": "markers",
"coverage": "Coverage Configuration",
"backend": "Backend",
"frontend": "Frontend",
"framework": "Framework"
},
"conflictDetection": {
"title": "Conflict Detection",
"riskLevel": {
"low": "Low Risk",
"medium": "Medium Risk",
"high": "High Risk",
"critical": "Critical Risk"
},
"mitigation": "Mitigation Strategy",
"riskFactors": "Risk Factors",
"testGaps": "Test Gaps",
"existingImplementations": "Existing Implementations",
"affectedModules": "Affected Modules"
} }
}, },
"summary": { "summary": {
"default": "Summary",
"title": "Session Summary", "title": "Session Summary",
"lines": "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."
} }
}, },
"implPlan": {
"title": "Implementation Plan",
"empty": {
"title": "No IMPL Plan Available",
"message": "This session has no implementation plan yet."
},
"viewFull": "View Full Plan ({count} lines)"
},
"conflict": {
"title": "Conflict Resolution",
"comingSoon": "Conflict Resolution (Coming Soon)",
"comingSoonMessage": "This tab will display conflict resolution decisions and user choices.",
"empty": {
"title": "No Conflict Resolution Data",
"message": "This session has no conflict resolution information."
},
"resolvedAt": "Resolved",
"userDecisions": "User Decisions",
"description": "Description",
"implications": "Implications",
"resolvedConflicts": "Resolved Conflicts",
"strategy": "Strategy"
},
"review": {
"title": "Code Review",
"comingSoon": "Code Review (Coming Soon)",
"comingSoonMessage": "This tab will display review findings and recommendations.",
"empty": {
"title": "No Review Data",
"message": "This session has no review information."
},
"noFindings": {
"title": "No Findings Found",
"message": "No findings match the current severity filter."
},
"filterBySeverity": "Filter by Severity",
"severity": {
"all": "All Severities",
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
}
},
"info": { "info": {
"created": "Created", "created": "Created",
"updated": "Updated", "updated": "Updated",

View File

@@ -66,6 +66,10 @@
"automation": "自动化" "automation": "自动化"
}, },
"templates": { "templates": {
"ccw-status-tracker": {
"name": "CCW 状态追踪器",
"description": "解析 CCW status.json 并显示当前/下一个命令"
},
"ccw-notify": { "ccw-notify": {
"name": "CCW 面板通知", "name": "CCW 面板通知",
"description": "当文件被写入时向 CCW 面板发送通知" "description": "当文件被写入时向 CCW 面板发送通知"

View File

@@ -0,0 +1,178 @@
{
"title": "CodexLens",
"description": "语义代码搜索引擎",
"bootstrap": "引导安装",
"bootstrapping": "安装中...",
"uninstall": "卸载",
"uninstalling": "卸载中...",
"confirmUninstall": "确定要卸载 CodexLens 吗?此操作无法撤销。",
"confirmUninstallTitle": "确认卸载",
"notInstalled": "CodexLens 尚未安装",
"comingSoon": "即将推出",
"tabs": {
"overview": "概览",
"settings": "设置",
"models": "模型",
"advanced": "高级"
},
"overview": {
"status": {
"installation": "安装状态",
"ready": "就绪",
"notReady": "未就绪",
"version": "版本",
"indexPath": "索引路径",
"indexCount": "索引数量"
},
"notInstalled": {
"title": "CodexLens 未安装",
"message": "请先安装 CodexLens 以使用语义代码搜索功能。"
},
"actions": {
"title": "快速操作",
"ftsFull": "FTS 全量",
"ftsFullDesc": "重建全文索引",
"ftsIncremental": "FTS 增量",
"ftsIncrementalDesc": "增量更新全文索引",
"vectorFull": "向量全量",
"vectorFullDesc": "重建向量索引",
"vectorIncremental": "向量增量",
"vectorIncrementalDesc": "增量更新向量索引"
},
"venv": {
"title": "Python 虚拟环境详情",
"pythonVersion": "Python 版本",
"venvPath": "虚拟环境路径",
"lastCheck": "最后检查时间"
}
},
"settings": {
"currentCount": "当前索引数量",
"currentWorkers": "当前工作线程",
"currentBatchSize": "当前批次大小",
"configTitle": "基本配置",
"indexDir": {
"label": "索引目录",
"placeholder": "~/.codexlens/indexes",
"hint": "存储代码索引的目录路径"
},
"maxWorkers": {
"label": "最大工作线程",
"hint": "API 并发处理线程数 (1-32)"
},
"batchSize": {
"label": "批次大小",
"hint": "每次批量处理的文件数量 (1-64)"
},
"validation": {
"indexDirRequired": "索引目录不能为空",
"maxWorkersRange": "工作线程数必须在 1-32 之间",
"batchSizeRange": "批次大小必须在 1-64 之间"
},
"save": "保存",
"saving": "保存中...",
"reset": "重置",
"saveSuccess": "配置已保存",
"saveFailed": "保存失败",
"configUpdated": "配置更新成功",
"saveError": "保存配置时出错",
"unknownError": "发生未知错误"
},
"gpu": {
"title": "GPU 设置",
"status": "GPU 状态",
"enabled": "已启用",
"available": "可用",
"unavailable": "不可用",
"supported": "您的系统支持 GPU 加速",
"notSupported": "您的系统不支持 GPU 加速",
"detect": "检测",
"detectSuccess": "GPU 检测完成",
"detectFailed": "GPU 检测失败",
"detectComplete": "检测到 {count} 个 GPU 设备",
"detectError": "检测 GPU 时出错",
"select": "选择",
"selected": "已选择",
"active": "当前",
"selectSuccess": "GPU 已选择",
"selectFailed": "GPU 选择失败",
"gpuSelected": "GPU 设备已启用",
"selectError": "选择 GPU 时出错",
"reset": "重置",
"resetSuccess": "GPU 已重置",
"resetFailed": "GPU 重置失败",
"gpuReset": "GPU 已禁用,将使用 CPU",
"resetError": "重置 GPU 时出错",
"unknownError": "发生未知错误",
"noDevices": "未检测到 GPU 设备",
"notAvailable": "GPU 功能不可用",
"unknownDevice": "未知设备",
"type": "类型",
"driver": "驱动版本",
"memory": "显存"
},
"advanced": {
"warningTitle": "敏感操作警告",
"warningMessage": "修改环境变量可能影响 CodexLens 的正常运行。请确保您了解每个变量的作用。",
"currentVars": "当前环境变量",
"settingsVars": "设置变量",
"customVars": "自定义变量",
"envEditor": "环境变量编辑器",
"envFile": "文件",
"envContent": "环境变量内容",
"envPlaceholder": "# 注释行以 # 开头\nKEY=value\nANOTHER_KEY=\"another value\"",
"envHint": "每行一个变量格式KEY=value。注释行以 # 开头",
"save": "保存",
"saving": "保存中...",
"reset": "重置",
"saveSuccess": "环境变量已保存",
"saveFailed": "保存失败",
"envUpdated": "环境变量更新成功,重启服务后生效",
"saveError": "保存环境变量时出错",
"unknownError": "发生未知错误",
"validation": {
"invalidKeys": "无效的变量名: {keys}"
},
"helpTitle": "格式说明",
"helpComment": "注释行以 # 开头",
"helpFormat": "变量格式KEY=value",
"helpQuotes": "包含空格的值建议使用引号",
"helpRestart": "修改后需要重启服务才能生效"
},
"models": {
"title": "模型管理",
"searchPlaceholder": "搜索模型...",
"downloading": "下载中...",
"status": {
"downloaded": "已下载",
"available": "可用"
},
"types": {
"embedding": "嵌入模型",
"reranker": "重排序模型"
},
"filters": {
"label": "筛选",
"all": "全部"
},
"actions": {
"download": "下载",
"delete": "删除",
"cancel": "取消"
},
"custom": {
"title": "自定义模型",
"placeholder": "HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)",
"description": "从 HuggingFace 下载自定义模型。请确保模型名称正确。"
},
"deleteConfirm": "确定要删除模型 {modelName} 吗?",
"notInstalled": {
"title": "CodexLens 未安装",
"description": "请先安装 CodexLens 以使用模型管理功能。"
},
"empty": {
"title": "没有找到模型",
"description": "尝试调整搜索或筛选条件"
}
}
}

View File

@@ -36,7 +36,10 @@
"submit": "提交", "submit": "提交",
"reset": "重置", "reset": "重置",
"resetDesc": "将所有用户偏好重置为默认值。此操作无法撤销。", "resetDesc": "将所有用户偏好重置为默认值。此操作无法撤销。",
"saving": "Saving...", "saving": "保存中...",
"deleting": "删除中...",
"merging": "合并中...",
"splitting": "拆分中...",
"resetConfirm": "确定要将所有设置重置为默认值吗?", "resetConfirm": "确定要将所有设置重置为默认值吗?",
"resetToDefaults": "重置为默认值", "resetToDefaults": "重置为默认值",
"enable": "启用", "enable": "启用",
@@ -51,7 +54,8 @@
"clearAll": "清除全部", "clearAll": "清除全部",
"select": "选择", "select": "选择",
"selectAll": "全选", "selectAll": "全选",
"deselectAll": "取消全选" "deselectAll": "取消全选",
"openMenu": "打开菜单"
}, },
"status": { "status": {
"active": "活跃", "active": "活跃",

View File

@@ -23,6 +23,7 @@ import skills from './skills.json';
import cliManager from './cli-manager.json'; import cliManager from './cli-manager.json';
import cliMonitor from './cli-monitor.json'; import cliMonitor from './cli-monitor.json';
import mcpManager from './mcp-manager.json'; import mcpManager from './mcp-manager.json';
import codexlens from './codexlens.json';
import theme from './theme.json'; import theme from './theme.json';
import executionMonitor from './execution-monitor.json'; import executionMonitor from './execution-monitor.json';
import cliHooks from './cli-hooks.json'; import cliHooks from './cli-hooks.json';
@@ -77,9 +78,10 @@ export default {
...flattenMessages(reviewSession, 'reviewSession'), ...flattenMessages(reviewSession, 'reviewSession'),
...flattenMessages(sessionDetail, 'sessionDetail'), ...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'), ...flattenMessages(skills, 'skills'),
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys ...flattenMessages(cliManager, 'cli-manager'),
...flattenMessages(cliMonitor, 'cliMonitor'), ...flattenMessages(cliMonitor, 'cliMonitor'),
...flattenMessages(mcpManager, 'mcp'), ...flattenMessages(mcpManager, 'mcp'),
...flattenMessages(codexlens, 'codexlens'),
...flattenMessages(theme, 'theme'), ...flattenMessages(theme, 'theme'),
...flattenMessages(cliHooks, 'cliHooks'), ...flattenMessages(cliHooks, 'cliHooks'),
...flattenMessages(executionMonitor, 'executionMonitor'), ...flattenMessages(executionMonitor, 'executionMonitor'),

View File

@@ -61,29 +61,130 @@
"updatedAt": "更新时间", "updatedAt": "更新时间",
"solutions": "{count, plural, one {解决方案} other {解决方案}}" "solutions": "{count, plural, one {解决方案} other {解决方案}}"
}, },
"detail": {
"title": "问题详情",
"tabs": {
"overview": "概览",
"solutions": "解决方案",
"history": "历史",
"json": "JSON"
},
"overview": {
"title": "标题",
"status": "状态",
"priority": "优先级",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"context": "上下文",
"labels": "标签",
"assignee": "受理人"
},
"solutions": {
"title": "解决方案",
"empty": "暂无解决方案",
"addSolution": "添加解决方案",
"boundSolution": "已绑定解决方案"
},
"history": {
"title": "历史记录",
"empty": "暂无历史记录"
}
},
"queue": { "queue": {
"title": "队列", "title": "队列",
"pageTitle": "问题队列", "pageTitle": "问题队列",
"description": "管理问题执行队列和执行组", "description": "管理问题执行队列和执行组",
"status": {
"pending": "待处理",
"ready": "就绪",
"executing": "执行中",
"completed": "已完成",
"failed": "失败",
"blocked": "已阻塞",
"active": "活动",
"inactive": "非活动"
},
"stats": { "stats": {
"totalItems": "总项目", "totalItems": "总项目",
"groups": "执行组", "groups": "执行组",
"tasks": "任务", "tasks": "任务",
"solutions": "解决方案" "solutions": "解决方案",
"items": "项目",
"executionGroups": "执行组"
}, },
"actions": { "actions": {
"activate": "激活", "activate": "激活",
"deactivate": "停用", "deactivate": "停用",
"delete": "删除", "delete": "删除",
"merge": "合并", "merge": "合并",
"split": "拆分",
"confirmDelete": "确定要删除此队列吗?" "confirmDelete": "确定要删除此队列吗?"
}, },
"executionGroup": "执行组", "executionGroup": "执行组",
"executionGroups": "执行组",
"parallelGroup": "并行组",
"sequentialGroup": "顺序组",
"items": "项目",
"itemCount": "{count} 项",
"groups": "组",
"parallel": "并行", "parallel": "并行",
"sequential": "顺序", "sequential": "顺序",
"emptyState": "无队列数据", "emptyState": "无队列数据",
"empty": "无数据",
"conflicts": "队列中检测到冲突", "conflicts": "队列中检测到冲突",
"noQueueData": "无队列数据" "noQueueData": "无队列数据",
"emptyState": {
"title": "暂无队列",
"description": "当前没有可用的执行队列"
},
"error": {
"title": "加载失败",
"message": "无法加载队列数据,请稍后重试"
},
"conflicts": {
"title": "队列冲突",
"description": "个冲突"
},
"deleteDialog": {
"title": "删除队列",
"description": "确定要删除此队列吗?此操作无法撤销。"
},
"mergeDialog": {
"title": "合并队列",
"targetQueueLabel": "目标队列ID",
"targetQueuePlaceholder": "输入要合并到的队列ID"
},
"splitDialog": {
"title": "拆分队列",
"selected": "已选择 {count}/{total} 项",
"selectAll": "全选",
"clearAll": "清空",
"noSelection": "请选择要拆分的项目",
"cannotSplitAll": "不能拆分所有项目,源队列至少需保留一项"
}
},
"solution": {
"issue": "问题",
"solution": "解决方案",
"shortIssue": "问题",
"shortSolution": "方案",
"tabs": {
"overview": "概览",
"tasks": "任务",
"json": "JSON"
},
"overview": {
"executionInfo": "执行信息",
"executionOrder": "执行顺序",
"semanticPriority": "语义优先级",
"group": "执行组",
"taskCount": "任务数量",
"dependencies": "依赖项",
"filesTouched": "涉及文件"
},
"tasks": {
"comingSoon": "任务列表即将推出"
}
}, },
"discovery": { "discovery": {
"title": "发现", "title": "发现",
@@ -97,6 +198,23 @@
"noSessions": "未发现会话", "noSessions": "未发现会话",
"noSessionsDescription": "启动新的问题发现会话以开始", "noSessionsDescription": "启动新的问题发现会话以开始",
"findingsDetail": "发现详情", "findingsDetail": "发现详情",
"selectSession": "选择会话以查看发现",
"sessionId": "会话ID",
"name": "名称",
"status": "状态",
"createdAt": "创建时间",
"completedAt": "完成时间",
"progress": "进度",
"findingsCount": "发现数量",
"export": "导出JSON",
"exportSelected": "导出选中的 {count} 项",
"exporting": "导出中...",
"exportAsIssues": "导出为问题",
"severityBreakdown": "严重程度分布",
"typeBreakdown": "类型分布",
"tabFindings": "发现",
"tabProgress": "进度",
"tabInfo": "会话信息",
"stats": { "stats": {
"totalSessions": "总会话数", "totalSessions": "总会话数",
"completed": "已完成", "completed": "已完成",
@@ -124,13 +242,37 @@
"critical": "严重", "critical": "严重",
"high": "高", "high": "高",
"medium": "中", "medium": "中",
"low": "低" "low": "低",
"unknown": "未知"
}, },
"type": { "type": {
"all": "全部类型" "all": "全部类型"
}, },
"exportedStatus": {
"all": "全部导出状态",
"exported": "已导出",
"notExported": "未导出"
},
"issueStatus": {
"all": "全部问题状态",
"hasIssue": "已关联问题",
"noIssue": "未关联问题"
},
"noFindings": "未发现结果", "noFindings": "未发现结果",
"export": "导出" "noFindingsDescription": "没有找到匹配的发现结果",
"searchPlaceholder": "搜索发现...",
"filterBySeverity": "按严重程度筛选",
"filterByType": "按类型筛选",
"filterByExported": "按导出状态筛选",
"filterByIssue": "按关联问题筛选",
"allSeverities": "全部严重程度",
"allTypes": "全部类型",
"showingCount": "显示 {count} 条发现",
"exported": "已导出",
"hasIssue": "已关联",
"export": "导出",
"selectAll": "全选",
"deselectAll": "取消全选"
}, },
"tabs": { "tabs": {
"findings": "发现", "findings": "发现",

View File

@@ -16,6 +16,7 @@
"prompts": "提示历史", "prompts": "提示历史",
"settings": "设置", "settings": "设置",
"mcp": "MCP 服务器", "mcp": "MCP 服务器",
"codexlens": "CodexLens",
"endpoints": "CLI 端点", "endpoints": "CLI 端点",
"installations": "安装", "installations": "安装",
"help": "帮助", "help": "帮助",

View File

@@ -6,13 +6,25 @@
"tabs": { "tabs": {
"tasks": "任务", "tasks": "任务",
"context": "上下文", "context": "上下文",
"summary": "摘要" "summary": "摘要",
"implPlan": "IMPL 计划",
"conflict": "冲突",
"review": "审查"
}, },
"tasks": { "tasks": {
"completed": "已完成", "completed": "已完成",
"inProgress": "进行中", "inProgress": "进行中",
"pending": "待处理", "pending": "待处理",
"blocked": "已阻塞", "blocked": "已阻塞",
"quickActions": {
"markAllPending": "全部待处理",
"markAllInProgress": "全部进行中",
"markAllCompleted": "全部已完成"
},
"statusUpdate": {
"success": "任务状态更新成功",
"error": "更新任务状态失败"
},
"status": { "status": {
"pending": "待处理", "pending": "待处理",
"inProgress": "进行中", "inProgress": "进行中",
@@ -36,6 +48,59 @@
"empty": { "empty": {
"title": "暂无上下文", "title": "暂无上下文",
"message": "该会话暂无上下文信息。" "message": "该会话暂无上下文信息。"
},
"explorations": {
"title": "探索结果",
"angles": "个角度",
"projectStructure": "项目结构",
"relevantFiles": "相关文件",
"patterns": "模式",
"dependencies": "依赖关系",
"integrationPoints": "集成点",
"testing": "测试"
},
"categories": {
"documentation": "文档",
"sourceCode": "源代码",
"tests": "测试"
},
"assets": {
"title": "资源",
"noData": "未找到资源",
"scope": "范围",
"contains": "包含"
},
"dependencies": {
"title": "依赖",
"internal": "内部依赖",
"external": "外部依赖",
"from": "来源",
"to": "目标",
"type": "类型"
},
"testContext": {
"title": "测试上下文",
"tests": "个测试",
"existingTests": "个现有测试",
"markers": "个标记",
"coverage": "覆盖率配置",
"backend": "后端",
"frontend": "前端",
"framework": "框架"
},
"conflictDetection": {
"title": "冲突检测",
"riskLevel": {
"low": "低风险",
"medium": "中等风险",
"high": "高风险",
"critical": "严重风险"
},
"mitigation": "缓解策略",
"riskFactors": "风险因素",
"testGaps": "测试缺失",
"existingImplementations": "现有实现",
"affectedModules": "受影响模块"
} }
}, },
"summary": { "summary": {
@@ -45,6 +110,50 @@
"message": "该会话暂无摘要。" "message": "该会话暂无摘要。"
} }
}, },
"implPlan": {
"title": "实现计划",
"empty": {
"title": "暂无 IMPL 计划",
"message": "该会话暂无实现计划。"
},
"viewFull": "查看完整计划({count} 行)"
},
"conflict": {
"title": "冲突解决",
"comingSoon": "冲突解决(即将推出)",
"comingSoonMessage": "此标签页将显示冲突解决决策和用户选择。",
"empty": {
"title": "暂无冲突解决数据",
"message": "该会话暂无冲突解决信息。"
},
"resolvedAt": "已解决",
"userDecisions": "用户决策",
"description": "描述",
"implications": "影响",
"resolvedConflicts": "已解决冲突",
"strategy": "策略"
},
"review": {
"title": "代码审查",
"comingSoon": "代码审查(即将推出)",
"comingSoonMessage": "此标签页将显示审查结果和建议。",
"empty": {
"title": "暂无审查数据",
"message": "该会话暂无审查信息。"
},
"noFindings": {
"title": "未发现审查结果",
"message": "没有匹配当前严重程度筛选器的审查结果。"
},
"filterBySeverity": "按严重程度筛选",
"severity": {
"all": "全部严重程度",
"critical": "严重",
"high": "高",
"medium": "中",
"low": "低"
}
},
"info": { "info": {
"created": "创建时间", "created": "创建时间",
"updated": "更新时间", "updated": "更新时间",

View File

@@ -0,0 +1,364 @@
// ========================================
// CodexLens Manager Page Tests
// ========================================
// Integration tests for CodexLens manager page with tabs
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { CodexLensManagerPage } from './CodexLensManagerPage';
import * as api from '@/lib/api';
// Mock api module
vi.mock('@/lib/api', () => ({
fetchCodexLensDashboardInit: vi.fn(),
bootstrapCodexLens: vi.fn(),
uninstallCodexLens: vi.fn(),
}));
// Mock hooks
vi.mock('@/hooks/useCodexLens', () => ({
useCodexLensDashboard: vi.fn(),
}));
vi.mock('@/hooks/useCodexLens', () => ({
useCodexLensDashboard: vi.fn(),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: vi.fn(() => ({
success: vi.fn(),
error: vi.fn(),
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
addToast: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
connectWebSocket: vi.fn(),
disconnectWebSocket: vi.fn(),
})),
}));
// Mock the mutations hook separately
vi.mock('@/hooks/useCodexLens', async () => {
return {
useCodexLensDashboard: (await import('@/hooks/useCodexLens')).useCodexLensDashboard,
useCodexLensMutations: vi.fn(),
};
});
// Mock window.confirm
global.confirm = vi.fn(() => true);
const mockDashboardData = {
installed: true,
status: {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
},
config: {
index_dir: '~/.codexlens/indexes',
index_count: 100,
api_max_workers: 4,
api_batch_size: 8,
},
semantic: { available: true },
};
const mockMutations = {
bootstrap: vi.fn().mockResolvedValue({ success: true }),
uninstall: vi.fn().mockResolvedValue({ success: true }),
isBootstrapping: false,
isUninstalling: false,
};
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
describe('CodexLensManagerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
});
describe('when installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should render page title and description', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
});
it('should render all tabs', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Overview/i)).toBeInTheDocument();
expect(screen.getByText(/Settings/i)).toBeInTheDocument();
expect(screen.getByText(/Models/i)).toBeInTheDocument();
expect(screen.getByText(/Advanced/i)).toBeInTheDocument();
});
it('should show uninstall button when installed', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
});
it('should switch between tabs', async () => {
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const settingsTab = screen.getByText(/Settings/i);
await user.click(settingsTab);
expect(settingsTab).toHaveAttribute('data-state', 'active');
});
it('should call refresh on button click', async () => {
const refetch = vi.fn();
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
expect(refetch).toHaveBeenCalledOnce();
});
});
describe('when not installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
semantic: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should show bootstrap button', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Bootstrap/i)).toBeInTheDocument();
});
it('should show not installed alert', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/CodexLens is not installed/i)).toBeInTheDocument();
});
it('should call bootstrap on button click', async () => {
const bootstrap = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
...mockMutations,
bootstrap,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const bootstrapButton = screen.getByText(/Bootstrap/i);
await user.click(bootstrapButton);
await waitFor(() => {
expect(bootstrap).toHaveBeenCalledOnce();
});
});
});
describe('uninstall flow', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
});
it('should show confirmation dialog on uninstall', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('uninstall'));
});
it('should call uninstall when confirmed', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
await waitFor(() => {
expect(uninstall).toHaveBeenCalledOnce();
});
});
it('should not call uninstall when cancelled', async () => {
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
const uninstall = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
expect(uninstall).not.toHaveBeenCalled();
});
});
describe('loading states', () => {
it('should show loading skeleton when loading', () => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
semantic: undefined,
isLoading: true,
isFetching: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
// Check for skeleton or loading indicator
const refreshButton = screen.getByText(/Refresh/i);
expect(refreshButton).toBeDisabled();
});
it('should disable refresh button when fetching', () => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
const refreshButton = screen.getByText(/Refresh/i);
expect(refreshButton).toBeDisabled();
});
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should display translated text in Chinese', () => {
render(<CodexLensManagerPage />, { locale: 'zh' });
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
expect(screen.getByText(/语义代码搜索引擎/i)).toBeInTheDocument();
expect(screen.getByText(/概览/i)).toBeInTheDocument();
expect(screen.getByText(/设置/i)).toBeInTheDocument();
expect(screen.getByText(/模型/i)).toBeInTheDocument();
expect(screen.getByText(/高级/i)).toBeInTheDocument();
});
it('should display translated uninstall button', () => {
render(<CodexLensManagerPage />, { locale: 'zh' });
expect(screen.getByText(/卸载/i)).toBeInTheDocument();
});
});
describe('error states', () => {
it('should handle API errors gracefully', () => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
semantic: undefined,
isLoading: false,
isFetching: false,
error: new Error('API Error'),
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
// Page should still render even with error
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,205 @@
// ========================================
// CodexLens Manager Page
// ========================================
// Manage CodexLens semantic code search with tabbed interface
// Supports Overview, Settings, Models, and Advanced tabs
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Sparkles,
RefreshCw,
Download,
Trash2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
import { OverviewTab } from '@/components/codexlens/OverviewTab';
import { SettingsTab } from '@/components/codexlens/SettingsTab';
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
import { GpuSelector } from '@/components/codexlens/GpuSelector';
import { ModelsTab } from '@/components/codexlens/ModelsTab';
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
import { cn } from '@/lib/utils';
export function CodexLensManagerPage() {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState('overview');
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
const {
installed,
status,
config,
isLoading,
isFetching,
refetch,
} = useCodexLensDashboard();
const {
bootstrap,
isBootstrapping,
uninstall,
isUninstalling,
} = useCodexLensMutations();
const handleRefresh = () => {
refetch();
};
const handleBootstrap = async () => {
const result = await bootstrap();
if (result.success) {
refetch();
}
};
const handleUninstall = async () => {
const result = await uninstall();
if (result.success) {
refetch();
}
setIsUninstallDialogOpen(false);
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-primary" />
{formatMessage({ id: 'codexlens.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'codexlens.description' })}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
{!installed ? (
<Button
onClick={handleBootstrap}
disabled={isBootstrapping}
>
<Download className={cn('w-4 h-4 mr-2', isBootstrapping && 'animate-spin')} />
{isBootstrapping
? formatMessage({ id: 'codexlens.bootstrapping' })
: formatMessage({ id: 'codexlens.bootstrap' })
}
</Button>
) : (
<AlertDialog open={isUninstallDialogOpen} onOpenChange={setIsUninstallDialogOpen}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={isUninstalling}
>
<Trash2 className={cn('w-4 h-4 mr-2', isUninstalling && 'animate-spin')} />
{isUninstalling
? formatMessage({ id: 'codexlens.uninstalling' })
: formatMessage({ id: 'codexlens.uninstall' })
}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'codexlens.confirmUninstallTitle' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'codexlens.confirmUninstall' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isUninstalling}>
{formatMessage({ id: 'common.actions.cancel' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleUninstall}
disabled={isUninstalling}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isUninstalling
? formatMessage({ id: 'codexlens.uninstalling' })
: formatMessage({ id: 'common.actions.confirm' })
}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
{/* Installation Status Alert */}
{!installed && !isLoading && (
<Card className="p-4 bg-warning/10 border-warning/20">
<p className="text-sm text-warning-foreground">
{formatMessage({ id: 'codexlens.notInstalled' })}
</p>
</Card>
)}
{/* Tabbed Interface */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="overview">
{formatMessage({ id: 'codexlens.tabs.overview' })}
</TabsTrigger>
<TabsTrigger value="settings">
{formatMessage({ id: 'codexlens.tabs.settings' })}
</TabsTrigger>
<TabsTrigger value="models">
{formatMessage({ id: 'codexlens.tabs.models' })}
</TabsTrigger>
<TabsTrigger value="advanced">
{formatMessage({ id: 'codexlens.tabs.advanced' })}
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewTab
installed={installed}
status={status}
config={config}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value="settings">
<SettingsTab enabled={installed} />
</TabsContent>
<TabsContent value="models">
<ModelsTab installed={installed} />
</TabsContent>
<TabsContent value="advanced">
<AdvancedTab enabled={installed} />
</TabsContent>
</Tabs>
</div>
);
}
export default CodexLensManagerPage;

View File

@@ -25,6 +25,8 @@ export function DiscoveryPage() {
setFilters, setFilters,
selectSession, selectSession,
exportFindings, exportFindings,
exportSelectedFindings,
isExporting,
} = useIssueDiscovery({ refetchInterval: 3000 }); } = useIssueDiscovery({ refetchInterval: 3000 });
if (error) { if (error) {
@@ -163,6 +165,8 @@ export function DiscoveryPage() {
filters={filters} filters={filters}
onFilterChange={setFilters} onFilterChange={setFilters}
onExport={exportFindings} onExport={exportFindings}
onExportSelected={exportSelectedFindings}
isExporting={isExporting}
/> />
)} )}
</div> </div>

View File

@@ -3,28 +3,219 @@
// ======================================== // ========================================
// Unified page for issues, queue, and discovery with tab navigation // Unified page for issues, queue, and discovery with tab navigation
import { useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
Plus,
RefreshCw,
Github,
Loader2,
} from 'lucide-react';
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader'; import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs'; import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel'; import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
import { QueuePanel } from '@/components/issue/hub/QueuePanel'; import { QueuePanel } from '@/components/issue/hub/QueuePanel';
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel'; import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { useIssues, useIssueMutations, useIssueQueue } from '@/hooks';
import { pullIssuesFromGitHub } from '@/lib/api';
import type { Issue } from '@/lib/api';
import { cn } from '@/lib/utils';
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
isCreating: boolean;
}) {
const { formatMessage } = useIntl();
const [title, setTitle] = useState('');
const [context, setContext] = useState('');
const [priority, setPriority] = useState<Issue['priority']>('medium');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
setTitle('');
setContext('');
setPriority('medium');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
className="mt-1"
required
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
</Button>
<Button type="submit" disabled={isCreating || !title.trim()}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export function IssueHubPage() { export function IssueHubPage() {
const { formatMessage } = useIntl();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const currentTab = (searchParams.get('tab') as IssueTab) || 'issues'; const currentTab = (searchParams.get('tab') as IssueTab) || 'issues';
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
// Issues data
const { refetch: refetchIssues, isFetching: isFetchingIssues } = useIssues();
// Queue data
const { refetch: refetchQueue, isFetching: isFetchingQueue } = useIssueQueue();
const { createIssue, isCreating } = useIssueMutations();
const setCurrentTab = (tab: IssueTab) => { const setCurrentTab = (tab: IssueTab) => {
setSearchParams({ tab }); setSearchParams({ tab });
}; };
// Issues tab handlers
const handleIssuesRefresh = useCallback(() => {
refetchIssues();
}, [refetchIssues]);
const handleGithubSync = useCallback(async () => {
setIsGithubSyncing(true);
try {
const result = await pullIssuesFromGitHub({ state: 'open', limit: 100 });
console.log('GitHub sync result:', result);
await refetchIssues();
} catch (error) {
console.error('GitHub sync failed:', error);
} finally {
setIsGithubSyncing(false);
}
}, [refetchIssues]);
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
await createIssue(data);
setIsNewIssueOpen(false);
};
// Queue tab handler
const handleQueueRefresh = useCallback(() => {
refetchQueue();
}, [refetchQueue]);
// Render action buttons based on current tab
const renderActionButtons = () => {
switch (currentTab) {
case 'issues':
return (
<>
<Button variant="outline" onClick={handleIssuesRefresh} disabled={isFetchingIssues}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingIssues && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button variant="outline" onClick={handleGithubSync} disabled={isGithubSyncing}>
<Github className={cn('w-4 h-4 mr-2', isGithubSyncing && 'animate-spin')} />
{formatMessage({ id: 'issues.actions.github' })}
</Button>
<Button onClick={() => setIsNewIssueOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.create' })}
</Button>
</>
);
case 'queue':
return (
<>
<Button variant="outline" onClick={handleQueueRefresh} disabled={isFetchingQueue}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingQueue && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</>
);
case 'discovery':
return null; // Discovery panel has its own controls
default:
return null;
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<IssueHubHeader currentTab={currentTab} /> {/* Header and action buttons on same row */}
<div className="flex items-center justify-between">
<IssueHubHeader currentTab={currentTab} />
{/* Action buttons - dynamic based on current tab */}
{renderActionButtons() && (
<div className="flex gap-2">
{renderActionButtons()}
</div>
)}
</div>
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} /> <IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
{currentTab === 'issues' && <IssuesPanel />} {currentTab === 'issues' && <IssuesPanel onCreateIssue={() => setIsNewIssueOpen(true)} />}
{currentTab === 'queue' && <QueuePanel />} {currentTab === 'queue' && <QueuePanel />}
{currentTab === 'discovery' && <DiscoveryPanel />} {currentTab === 'discovery' && <DiscoveryPanel />}
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
// ======================================== // ========================================
// SessionDetailPage Component // SessionDetailPage Component
// ======================================== // ========================================
// Session detail page with tabs for tasks, context, and summary // Session detail page with tabs for tasks, context, summary, impl-plan, conflict, and review
import * as React from 'react'; import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@@ -13,18 +13,24 @@ import {
Package, Package,
FileText, FileText,
XCircle, XCircle,
Ruler,
Scale,
Search,
} from 'lucide-react'; } from 'lucide-react';
import { useSessionDetail } from '@/hooks/useSessionDetail'; import { useSessionDetail } from '@/hooks/useSessionDetail';
import { TaskListTab } from './session-detail/TaskListTab'; import { TaskListTab } from './session-detail/TaskListTab';
import { ContextTab } from './session-detail/ContextTab'; import { ContextTab } from './session-detail/ContextTab';
import { SummaryTab } from './session-detail/SummaryTab'; import { SummaryTab } from './session-detail/SummaryTab';
import ImplPlanTab from './session-detail/ImplPlanTab';
import { ConflictTab } from './session-detail/ConflictTab';
import { ReviewTab } from './session-detail/ReviewTab';
import { TaskDrawer } from '@/components/shared/TaskDrawer'; import { TaskDrawer } from '@/components/shared/TaskDrawer';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import type { TaskData } from '@/types/store'; import type { TaskData } from '@/types/store';
type TabValue = 'tasks' | 'context' | 'summary'; type TabValue = 'tasks' | 'context' | 'summary' | 'impl-plan' | 'conflict' | 'review';
/** /**
* SessionDetailPage component - Main session detail page with tabs * SessionDetailPage component - Main session detail page with tabs
@@ -92,9 +98,10 @@ export function SessionDetailPage() {
); );
} }
const { session, context, summary } = sessionDetail; const { session, context, summary, summaries, implPlan, conflicts, review } = sessionDetail;
const tasks = session.tasks || []; const tasks = session.tasks || [];
const completedTasks = tasks.filter((t) => t.status === 'completed').length; const completedTasks = tasks.filter((t) => t.status === 'completed').length;
const hasReview = session.has_review || session.review;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -158,6 +165,20 @@ export function SessionDetailPage() {
<FileText className="h-4 w-4 mr-2" /> <FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.summary' })} {formatMessage({ id: 'sessionDetail.tabs.summary' })}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="impl-plan">
<Ruler className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.implPlan' })}
</TabsTrigger>
<TabsTrigger value="conflict">
<Scale className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.conflict' })}
</TabsTrigger>
{hasReview && (
<TabsTrigger value="review">
<Search className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.review' })}
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="tasks" className="mt-4"> <TabsContent value="tasks" className="mt-4">
@@ -169,8 +190,22 @@ export function SessionDetailPage() {
</TabsContent> </TabsContent>
<TabsContent value="summary" className="mt-4"> <TabsContent value="summary" className="mt-4">
<SummaryTab summary={summary} /> <SummaryTab summary={summary} summaries={summaries} />
</TabsContent> </TabsContent>
<TabsContent value="impl-plan" className="mt-4">
<ImplPlanTab implPlan={implPlan} />
</TabsContent>
<TabsContent value="conflict" className="mt-4">
<ConflictTab conflicts={conflicts as any} />
</TabsContent>
{hasReview && (
<TabsContent value="review" className="mt-4">
<ReviewTab review={review as any} />
</TabsContent>
)}
</Tabs> </Tabs>
{/* Description (if exists) */} {/* Description (if exists) */}

View File

@@ -33,3 +33,4 @@ export { RulesManagerPage } from './RulesManagerPage';
export { PromptHistoryPage } from './PromptHistoryPage'; export { PromptHistoryPage } from './PromptHistoryPage';
export { ExplorerPage } from './ExplorerPage'; export { ExplorerPage } from './ExplorerPage';
export { GraphExplorerPage } from './GraphExplorerPage'; export { GraphExplorerPage } from './GraphExplorerPage';
export { CodexLensManagerPage } from './CodexLensManagerPage';

View File

@@ -0,0 +1,176 @@
// ========================================
// ConflictTab Component
// ========================================
// Conflict tab for session detail page - displays conflict resolution decisions
import { useIntl } from 'react-intl';
import {
Scale,
ChevronDown,
ChevronRight,
CheckCircle2,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/Collapsible';
// Type definitions for conflict resolution data
export interface UserDecision {
choice: string;
description?: string;
implications?: string;
}
export interface ResolvedConflict {
id: string;
category?: string;
brief?: string;
strategy?: string;
}
export interface ConflictResolutionData {
session_id: string;
resolved_at?: string;
user_decisions?: Record<string, UserDecision>;
resolved_conflicts?: ResolvedConflict[];
}
export interface ConflictTabProps {
conflicts?: ConflictResolutionData;
}
/**
* ConflictTab component - Display conflict resolution decisions
*/
export function ConflictTab({ conflicts }: ConflictTabProps) {
const { formatMessage } = useIntl();
if (!conflicts) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Scale className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
</p>
</div>
);
}
const hasUserDecisions = conflicts.user_decisions && Object.keys(conflicts.user_decisions).length > 0;
const hasResolvedConflicts = conflicts.resolved_conflicts && conflicts.resolved_conflicts.length > 0;
if (!hasUserDecisions && !hasResolvedConflicts) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Scale className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Resolved At */}
{conflicts.resolved_at && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-success" />
<span>
{formatMessage({ id: 'sessionDetail.conflict.resolvedAt' })}:{' '}
{new Date(conflicts.resolved_at).toLocaleString()}
</span>
</div>
)}
{/* User Decisions Section */}
{hasUserDecisions && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'sessionDetail.conflict.userDecisions' })}
</h3>
<div className="space-y-3">
{Object.entries(conflicts.user_decisions!).map(([key, decision], index) => (
<Collapsible key={key} defaultOpen={index < 3}>
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 rounded-lg hover:bg-accent/50 transition-colors">
<ChevronRight className="h-4 w-4 transition-transform data-[state=open]:rotate-90" />
<span className="font-medium text-sm flex-1">{key}</span>
<Badge variant="success" className="text-xs">
{decision.choice}
</Badge>
</CollapsibleTrigger>
<CollapsibleContent className="pl-6 pr-3 pb-3 space-y-2">
{decision.description && (
<div className="text-sm text-foreground">
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.description' })}:</span>{' '}
{decision.description}
</div>
)}
{decision.implications && (
<div className="text-sm text-muted-foreground">
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.implications' })}:</span>{' '}
{decision.implications}
</div>
)}
</CollapsibleContent>
</Collapsible>
))}
</div>
</CardContent>
</Card>
)}
{/* Resolved Conflicts Section */}
{hasResolvedConflicts && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'sessionDetail.conflict.resolvedConflicts' })}
</h3>
<div className="space-y-3">
{conflicts.resolved_conflicts!.map((conflict) => (
<Collapsible key={conflict.id}>
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 rounded-lg hover:bg-accent/50 transition-colors">
<ChevronDown className="h-4 w-4 transition-transform data-[state=closed]:rotate-[-90deg]" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{conflict.id}</span>
{conflict.category && (
<Badge variant="outline" className="text-xs">
{conflict.category}
</Badge>
)}
</div>
{conflict.brief && (
<p className="text-xs text-muted-foreground mt-1">{conflict.brief}</p>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="pl-6 pr-3 pb-3">
{conflict.strategy && (
<div className="text-sm text-foreground">
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.strategy' })}:</span>{' '}
{conflict.strategy}
</div>
)}
</CollapsibleContent>
</Collapsible>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -15,6 +15,13 @@ import {
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import type { SessionDetailContext } from '@/lib/api'; import type { SessionDetailContext } from '@/lib/api';
import {
ExplorationsSection,
AssetsCard,
DependenciesCard,
TestContextCard,
ConflictDetectionCard,
} from '@/components/session-detail/context';
export interface ContextTabProps { export interface ContextTabProps {
context?: SessionDetailContext; context?: SessionDetailContext;
@@ -44,12 +51,16 @@ export function ContextTab({ context }: ContextTabProps) {
const hasFocusPaths = context.focus_paths && context.focus_paths.length > 0; const hasFocusPaths = context.focus_paths && context.focus_paths.length > 0;
const hasArtifacts = context.artifacts && context.artifacts.length > 0; const hasArtifacts = context.artifacts && context.artifacts.length > 0;
const hasSharedContext = context.shared_context; const hasSharedContext = context.shared_context;
const hasExtendedContext = context.context;
const hasExplorations = context.explorations;
if ( if (
!hasRequirements && !hasRequirements &&
!hasFocusPaths && !hasFocusPaths &&
!hasArtifacts && !hasArtifacts &&
!hasSharedContext !hasSharedContext &&
!hasExtendedContext &&
!hasExplorations
) { ) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
@@ -66,7 +77,7 @@ export function ContextTab({ context }: ContextTabProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Requirements */} {/* Original Context Sections - Maintained for backward compatibility */}
{hasRequirements && ( {hasRequirements && (
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
@@ -90,7 +101,6 @@ export function ContextTab({ context }: ContextTabProps) {
</Card> </Card>
)} )}
{/* Focus Paths */}
{hasFocusPaths && ( {hasFocusPaths && (
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
@@ -113,7 +123,6 @@ export function ContextTab({ context }: ContextTabProps) {
</Card> </Card>
)} )}
{/* Artifacts */}
{hasArtifacts && ( {hasArtifacts && (
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
@@ -133,7 +142,6 @@ export function ContextTab({ context }: ContextTabProps) {
</Card> </Card>
)} )}
{/* Shared Context */}
{hasSharedContext && ( {hasSharedContext && (
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
@@ -142,7 +150,6 @@ export function ContextTab({ context }: ContextTabProps) {
{formatMessage({ id: 'sessionDetail.context.sharedContext' })} {formatMessage({ id: 'sessionDetail.context.sharedContext' })}
</h3> </h3>
{/* Tech Stack */}
{context.shared_context!.tech_stack && context.shared_context!.tech_stack.length > 0 && ( {context.shared_context!.tech_stack && context.shared_context!.tech_stack.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
@@ -158,7 +165,6 @@ export function ContextTab({ context }: ContextTabProps) {
</div> </div>
)} )}
{/* Conventions */}
{context.shared_context!.conventions && context.shared_context!.conventions.length > 0 && ( {context.shared_context!.conventions && context.shared_context!.conventions.length > 0 && (
<div> <div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
@@ -177,6 +183,25 @@ export function ContextTab({ context }: ContextTabProps) {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* New Extended Context Sections from context-package.json */}
{hasExplorations && <ExplorationsSection data={context.explorations} />}
{hasExtendedContext && context.context!.assets && (
<AssetsCard data={context.context!.assets} />
)}
{hasExtendedContext && context.context!.dependencies && (
<DependenciesCard data={context.context!.dependencies} />
)}
{hasExtendedContext && context.context!.test_context && (
<TestContextCard data={context.context!.test_context} />
)}
{hasExtendedContext && context.context!.conflict_detection && (
<ConflictDetectionCard data={context.context!.conflict_detection} />
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,113 @@
// ========================================
// ImplPlanTab Component
// ========================================
// IMPL Plan tab for session detail page
import * as React from 'react';
import { useIntl } from 'react-intl';
import { Ruler, Eye } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import MarkdownModal from '@/components/shared/MarkdownModal';
// ========================================
// Types
// ========================================
export interface ImplPlanTabProps {
implPlan?: string;
}
// ========================================
// Component
// ========================================
/**
* ImplPlanTab component - Display IMPL_PLAN.md content with modal viewer
*
* @example
* ```tsx
* <ImplPlanTab
* implPlan="# Implementation Plan\n\n## Steps..."}
* />
* ```
*/
export function ImplPlanTab({ implPlan }: ImplPlanTabProps) {
const { formatMessage } = useIntl();
const [isModalOpen, setIsModalOpen] = React.useState(false);
if (!implPlan) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Ruler className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.implPlan.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.implPlan.empty.message' })}
</p>
</div>
);
}
// Get preview (first 5 lines)
const lines = implPlan.split('\n');
const preview = lines.slice(0, 5).join('\n');
const hasMore = lines.length > 5;
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Ruler className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.implPlan.title' })}
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setIsModalOpen(true)}
>
<Eye className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.actions.view' })}
</Button>
</div>
</CardHeader>
<CardContent>
<pre className="text-sm text-muted-foreground whitespace-pre-wrap">
{preview}{hasMore && '\n...'}
</pre>
{hasMore && (
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => setIsModalOpen(true)}
className="w-full"
>
{formatMessage({ id: 'sessionDetail.implPlan.viewFull' }, { count: lines.length })}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Modal Viewer */}
<MarkdownModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="IMPL_PLAN.md"
content={implPlan}
contentType="markdown"
maxWidth="3xl"
/>
</>
);
}
// ========================================
// Exports
// ========================================
export default ImplPlanTab;

View File

@@ -0,0 +1,227 @@
// ========================================
// ReviewTab Component
// ========================================
// Review tab for session detail page - displays review findings by dimension
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Search,
ChevronRight,
AlertCircle,
AlertTriangle,
Info,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/Select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/Collapsible';
// Type definitions for review data
export interface ReviewFinding {
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
description?: string;
location?: string;
code?: string;
}
export interface ReviewDimension {
name: string;
findings?: ReviewFinding[];
summary?: string;
}
export interface ReviewTabProps {
review?: {
dimensions?: ReviewDimension[];
};
}
type SeverityFilter = 'all' | 'critical' | 'high' | 'medium' | 'low';
/**
* Get severity color variant for badges
*/
function getSeverityVariant(severity: string): 'destructive' | 'warning' | 'default' | 'secondary' {
switch (severity) {
case 'critical':
return 'destructive';
case 'high':
return 'warning';
case 'medium':
return 'default';
case 'low':
return 'secondary';
default:
return 'secondary';
}
}
/**
* Get border color class for severity
*/
function getSeverityBorderClass(severity: string): string {
switch (severity) {
case 'critical':
return 'border-destructive';
case 'high':
return 'border-orange-500';
case 'medium':
return 'border-yellow-500';
case 'low':
return 'border-blue-500';
default:
return 'border-border';
}
}
/**
* Get severity icon
*/
function getSeverityIcon(severity: string) {
switch (severity) {
case 'critical':
case 'high':
return <AlertCircle className="h-4 w-4" />;
case 'medium':
return <AlertTriangle className="h-4 w-4" />;
case 'low':
return <Info className="h-4 w-4" />;
default:
return null;
}
}
/**
* ReviewTab component - Display review findings by dimension
*/
export function ReviewTab({ review }: ReviewTabProps) {
const { formatMessage } = useIntl();
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
if (!review || !review.dimensions || review.dimensions.length === 0) {
return (
<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: 'sessionDetail.review.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.review.empty.message' })}
</p>
</div>
);
}
// Filter findings by severity
const filteredDimensions = review.dimensions.map((dimension) => ({
...dimension,
findings: dimension.findings?.filter((finding) =>
severityFilter === 'all' || finding.severity === severityFilter
),
})).filter((dimension) => dimension.findings && dimension.findings.length > 0);
const hasFindings = filteredDimensions.some((d) => d.findings && d.findings.length > 0);
if (!hasFindings) {
return (
<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: 'sessionDetail.review.noFindings.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.review.noFindings.message' })}
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Severity Filter */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'sessionDetail.review.filterBySeverity' })}
</span>
<Select value={severityFilter} onValueChange={(v) => setSeverityFilter(v as SeverityFilter)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{formatMessage({ id: 'sessionDetail.review.severity.all' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'sessionDetail.review.severity.critical' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'sessionDetail.review.severity.high' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'sessionDetail.review.severity.medium' })}</SelectItem>
<SelectItem value="low">{formatMessage({ id: 'sessionDetail.review.severity.low' })}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Dimensions with Findings */}
{filteredDimensions.map((dimension) => {
if (!dimension.findings || dimension.findings.length === 0) return null;
return (
<Card key={dimension.name}>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">{dimension.name}</h3>
<Badge variant="secondary">{dimension.findings.length}</Badge>
</div>
{dimension.summary && (
<p className="text-sm text-muted-foreground mb-4">{dimension.summary}</p>
)}
<div className="space-y-3">
{dimension.findings.map((finding, findingIndex) => (
<Collapsible key={`${dimension.name}-${findingIndex}`} className={`border-l-4 ${getSeverityBorderClass(finding.severity)} rounded-r-lg`}>
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 hover:bg-accent/50 transition-colors">
<ChevronRight className="h-4 w-4 transition-transform data-[state=open]:rotate-90" />
<div className="flex-1">
<div className="flex items-center gap-2">
{getSeverityIcon(finding.severity)}
<span className="font-medium text-sm">{finding.title}</span>
<Badge variant={getSeverityVariant(finding.severity)} className="text-xs">
{formatMessage({ id: `sessionDetail.review.severity.${finding.severity}` })}
</Badge>
</div>
{finding.location && (
<p className="text-xs text-muted-foreground mt-1 font-mono">{finding.location}</p>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="px-6 pb-3 space-y-2">
{finding.description && (
<div className="text-sm text-foreground">
{finding.description}
</div>
)}
{finding.code && (
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto">
<code>{finding.code}</code>
</pre>
)}
</CollapsibleContent>
</Collapsible>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -1,23 +1,60 @@
// ======================================== // ========================================
// SummaryTab Component // SummaryTab Component
// ======================================== // ========================================
// Summary tab for session detail page // Summary tab for session detail page with multiple summaries support
import * as React from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { FileText } from 'lucide-react'; import { FileText, Eye } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import MarkdownModal from '@/components/shared/MarkdownModal';
// ========================================
// Types
// ========================================
export interface SummaryItem {
name: string;
content: string;
}
export interface SummaryTabProps { export interface SummaryTabProps {
summary?: string; summary?: string;
summaries?: SummaryItem[];
} }
/** // ========================================
* SummaryTab component - Display session summary // Component
*/ // ========================================
export function SummaryTab({ summary }: SummaryTabProps) {
const { formatMessage } = useIntl();
if (!summary) { /**
* SummaryTab component - Display session summary/summaries with modal viewer
*
* @example
* ```tsx
* <SummaryTab
* summaries={[{ name: 'Plan Summary', content: '...' }]}
* />
* ```
*/
export function SummaryTab({ summary, summaries }: SummaryTabProps) {
const { formatMessage } = useIntl();
const [selectedSummary, setSelectedSummary] = React.useState<SummaryItem | null>(null);
// Use summaries array if available, otherwise fallback to single summary
const summaryList: SummaryItem[] = React.useMemo(() => {
if (summaries && summaries.length > 0) {
return summaries;
}
if (summary) {
return [{ name: formatMessage({ id: 'sessionDetail.summary.default' }), content: summary }];
}
return [];
}, [summaries, summary, formatMessage]);
if (summaryList.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground mb-4" /> <FileText className="h-12 w-12 text-muted-foreground mb-4" />
@@ -32,16 +69,97 @@ export function SummaryTab({ summary }: SummaryTabProps) {
} }
return ( return (
<Card> <>
<CardContent className="p-6"> <div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2"> {summaryList.length === 1 ? (
<FileText className="w-5 h-5" /> // Single summary - inline display
{formatMessage({ id: 'sessionDetail.summary.title' })} <Card>
</h3> <CardContent className="p-6">
<div className="prose prose-sm max-w-none text-foreground"> <h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<p className="whitespace-pre-wrap">{summary}</p> <FileText className="w-5 h-5" />
{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>
{/* Modal Viewer */}
<MarkdownModal
isOpen={!!selectedSummary}
onClose={() => setSelectedSummary(null)}
title={selectedSummary?.name || ''}
content={selectedSummary?.content || ''}
contentType="markdown"
/>
</>
);
}
// ========================================
// Sub-Components
// ========================================
interface SummaryCardProps {
summary: SummaryItem;
onClick: () => void;
}
function SummaryCard({ summary, onClick }: SummaryCardProps) {
const { formatMessage } = useIntl();
// Get preview (first 3 lines)
const lines = summary.content.split('\n');
const preview = lines.slice(0, 3).join('\n');
const hasMore = lines.length > 3;
return (
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={onClick}
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="w-5 h-5" />
{summary.name}
</CardTitle>
<Button variant="ghost" size="sm">
<Eye className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.actions.view' })}
</Button>
</div> </div>
</CardHeader>
<CardContent>
<pre className="text-sm text-muted-foreground whitespace-pre-wrap">
{preview}{hasMore && '\n...'}
</pre>
{hasMore && (
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<Badge variant="secondary">
{lines.length} {formatMessage({ id: 'sessionDetail.summary.lines' })}
</Badge>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
} }
// ========================================
// Exports
// ========================================
export default SummaryTab;

View File

@@ -3,51 +3,27 @@
// ======================================== // ========================================
// Tasks tab for session detail page // Tasks tab for session detail page
import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { import {
ListChecks, ListChecks,
Loader2,
Circle,
CheckCircle,
Code, Code,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; 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 { bulkUpdateTaskStatus, updateTaskStatus } from '@/lib/api';
export interface TaskListTabProps { export interface TaskListTabProps {
session: SessionMetadata; session: SessionMetadata;
onTaskClick?: (task: TaskData) => void; onTaskClick?: (task: TaskData) => void;
} }
// Status configuration export interface TaskListTabProps {
const taskStatusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' | null; icon: React.ComponentType<{ className?: string }> }> = { session: SessionMetadata;
pending: { onTaskClick?: (task: TaskData) => void;
label: 'sessionDetail.tasks.status.pending', }
variant: 'secondary',
icon: Circle,
},
in_progress: {
label: 'sessionDetail.tasks.status.inProgress',
variant: 'warning',
icon: Loader2,
},
completed: {
label: 'sessionDetail.tasks.status.completed',
variant: 'success',
icon: CheckCircle,
},
blocked: {
label: 'sessionDetail.tasks.status.blocked',
variant: 'destructive',
icon: Circle,
},
skipped: {
label: 'sessionDetail.tasks.status.skipped',
variant: 'default',
icon: Circle,
},
};
/** /**
* TaskListTab component - Display tasks in a list format * TaskListTab component - Display tasks in a list format
@@ -59,34 +35,129 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
const completed = tasks.filter((t) => t.status === 'completed').length; const completed = tasks.filter((t) => t.status === 'completed').length;
const inProgress = tasks.filter((t) => t.status === 'in_progress').length; const inProgress = tasks.filter((t) => t.status === 'in_progress').length;
const pending = tasks.filter((t) => t.status === 'pending').length; const pending = tasks.filter((t) => t.status === 'pending').length;
const blocked = tasks.filter((t) => t.status === 'blocked').length;
// Loading states for bulk actions
const [isLoadingPending, setIsLoadingPending] = useState(false);
const [isLoadingInProgress, setIsLoadingInProgress] = useState(false);
const [isLoadingCompleted, setIsLoadingCompleted] = useState(false);
// Local task state for optimistic updates
const [localTasks, setLocalTasks] = useState<TaskData[]>(tasks);
// Update local tasks when session tasks change
if (tasks !== localTasks && !isLoadingPending && !isLoadingInProgress && !isLoadingCompleted) {
setLocalTasks(tasks);
}
// Get session path for API calls
const sessionPath = (session as any).path || session.session_id;
// Bulk action handlers
const handleMarkAllPending = async () => {
const targetTasks = localTasks.filter((t) => t.status === 'pending');
if (targetTasks.length === 0) return;
setIsLoadingPending(true);
try {
const taskIds = targetTasks.map((t) => t.task_id);
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'pending');
if (result.success) {
// Optimistic update - will be refreshed when parent re-renders
} else {
console.error('[TaskListTab] Failed to mark all as pending:', result.error);
}
} catch (error) {
console.error('[TaskListTab] Failed to mark all as pending:', error);
} finally {
setIsLoadingPending(false);
}
};
const handleMarkAllInProgress = async () => {
const targetTasks = localTasks.filter((t) => t.status === 'in_progress');
if (targetTasks.length === 0) return;
setIsLoadingInProgress(true);
try {
const taskIds = targetTasks.map((t) => t.task_id);
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'in_progress');
if (result.success) {
// Optimistic update - will be refreshed when parent re-renders
} else {
console.error('[TaskListTab] Failed to mark all as in_progress:', result.error);
}
} catch (error) {
console.error('[TaskListTab] Failed to mark all as in_progress:', error);
} finally {
setIsLoadingInProgress(false);
}
};
const handleMarkAllCompleted = async () => {
const targetTasks = localTasks.filter((t) => t.status === 'completed');
if (targetTasks.length === 0) return;
setIsLoadingCompleted(true);
try {
const taskIds = targetTasks.map((t) => t.task_id);
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'completed');
if (result.success) {
// Optimistic update - will be refreshed when parent re-renders
} else {
console.error('[TaskListTab] Failed to mark all as completed:', result.error);
}
} catch (error) {
console.error('[TaskListTab] Failed to mark all as completed:', error);
} finally {
setIsLoadingCompleted(false);
}
};
// Individual task status change handler
const handleTaskStatusChange = async (taskId: string, newStatus: TaskStatus) => {
const previousTasks = [...localTasks];
const previousTask = previousTasks.find((t) => t.task_id === taskId);
if (!previousTask) return;
// Optimistic update
setLocalTasks((prev) =>
prev.map((t) =>
t.task_id === taskId ? { ...t, status: newStatus } : t
)
);
try {
const result = await updateTaskStatus(sessionPath, taskId, newStatus);
if (!result.success) {
// Rollback on error
setLocalTasks(previousTasks);
console.error('[TaskListTab] Failed to update task status:', result.error);
}
} catch (error) {
// Rollback on error
setLocalTasks(previousTasks);
console.error('[TaskListTab] Failed to update task status:', error);
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Stats Bar */} {/* Stats Bar with Bulk Actions */}
<div className="flex flex-wrap items-center gap-4 p-4 bg-background rounded-lg border"> <TaskStatsBar
<span className="flex items-center gap-1 text-sm"> completed={completed}
<CheckCircle className="h-4 w-4 text-success" /> inProgress={inProgress}
<strong>{completed}</strong> {formatMessage({ id: 'sessionDetail.tasks.completed' })} pending={pending}
</span> onMarkAllPending={handleMarkAllPending}
<span className="flex items-center gap-1 text-sm"> onMarkAllInProgress={handleMarkAllInProgress}
<Loader2 className="h-4 w-4 text-warning" /> onMarkAllCompleted={handleMarkAllCompleted}
<strong>{inProgress}</strong> {formatMessage({ id: 'sessionDetail.tasks.inProgress' })} isLoadingPending={isLoadingPending}
</span> isLoadingInProgress={isLoadingInProgress}
<span className="flex items-center gap-1 text-sm"> isLoadingCompleted={isLoadingCompleted}
<Circle className="h-4 w-4 text-muted-foreground" /> />
<strong>{pending}</strong> {formatMessage({ id: 'sessionDetail.tasks.pending' })}
</span>
{blocked > 0 && (
<span className="flex items-center gap-1 text-sm">
<Circle className="h-4 w-4 text-destructive" />
<strong>{blocked}</strong> {formatMessage({ id: 'sessionDetail.tasks.blocked' })}
</span>
)}
</div>
{/* Tasks List */} {/* Tasks List */}
{tasks.length === 0 ? ( {localTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<ListChecks className="h-12 w-12 text-muted-foreground mb-4" /> <ListChecks className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
@@ -98,10 +169,7 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{tasks.map((task, index) => { {localTasks.map((task, index) => {
const currentStatusConfig = task.status ? taskStatusConfig[task.status] : taskStatusConfig.pending;
const StatusIcon = currentStatusConfig.icon;
return ( return (
<Card <Card
key={task.task_id || index} key={task.task_id || index}
@@ -111,18 +179,19 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
<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-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 flex-wrap">
<span className="text-xs font-mono text-muted-foreground"> <span className="text-xs font-mono text-muted-foreground">
{task.task_id} {task.task_id}
</span> </span>
<Badge variant={currentStatusConfig.variant} className="gap-1"> <TaskStatusDropdown
<StatusIcon className="h-3 w-3" /> currentStatus={task.status as TaskStatus}
{formatMessage({ id: currentStatusConfig.label })} onStatusChange={(newStatus) => handleTaskStatusChange(task.task_id, newStatus)}
</Badge> size="sm"
/>
{task.priority && ( {task.priority && (
<Badge variant="outline" className="text-xs"> <span className="text-xs text-muted-foreground">
{task.priority} {task.priority}
</Badge> </span>
)} )}
</div> </div>
<h4 className="font-medium text-foreground text-sm"> <h4 className="font-medium text-foreground text-sm">

View File

@@ -36,6 +36,7 @@ import {
PromptHistoryPage, PromptHistoryPage,
ExplorerPage, ExplorerPage,
GraphExplorerPage, GraphExplorerPage,
CodexLensManagerPage,
} from '@/pages'; } from '@/pages';
/** /**
@@ -141,6 +142,10 @@ const routes: RouteObject[] = [
path: 'settings/rules', path: 'settings/rules',
element: <RulesManagerPage />, element: <RulesManagerPage />,
}, },
{
path: 'settings/codexlens',
element: <CodexLensManagerPage />,
},
{ {
path: 'help', path: 'help',
element: <HelpPage />, element: <HelpPage />,
@@ -206,6 +211,7 @@ export const ROUTES = {
ENDPOINTS: '/settings/endpoints', ENDPOINTS: '/settings/endpoints',
INSTALLATIONS: '/settings/installations', INSTALLATIONS: '/settings/installations',
SETTINGS_RULES: '/settings/rules', SETTINGS_RULES: '/settings/rules',
CODEXLENS_MANAGER: '/settings/codexlens',
HELP: '/help', HELP: '/help',
EXPLORER: '/explorer', EXPLORER: '/explorer',
GRAPH: '/graph', GRAPH: '/graph',

View File

@@ -111,6 +111,84 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'issues.discovery.status.failed': 'Failed', 'issues.discovery.status.failed': 'Failed',
'issues.discovery.progress': 'Progress', 'issues.discovery.progress': 'Progress',
'issues.discovery.findings': 'Findings', 'issues.discovery.findings': 'Findings',
// CodexLens
'codexlens.title': 'CodexLens',
'codexlens.description': 'Semantic code search engine',
'codexlens.bootstrap': 'Bootstrap',
'codexlens.bootstrapping': 'Bootstrapping...',
'codexlens.uninstall': 'Uninstall',
'codexlens.uninstalling': 'Uninstalling...',
'codexlens.confirmUninstall': 'Are you sure you want to uninstall CodexLens?',
'codexlens.notInstalled': 'CodexLens is not installed',
'codexlens.comingSoon': 'Coming Soon',
'codexlens.tabs.overview': 'Overview',
'codexlens.tabs.settings': 'Settings',
'codexlens.tabs.models': 'Models',
'codexlens.tabs.advanced': 'Advanced',
'codexlens.overview.status.installation': 'Installation Status',
'codexlens.overview.status.ready': 'Ready',
'codexlens.overview.status.notReady': 'Not Ready',
'codexlens.overview.status.version': 'Version',
'codexlens.overview.status.indexPath': 'Index Path',
'codexlens.overview.status.indexCount': 'Index Count',
'codexlens.overview.notInstalled.title': 'CodexLens Not Installed',
'codexlens.overview.notInstalled.message': 'Please install CodexLens to use semantic code search features.',
'codexlens.overview.actions.title': 'Quick Actions',
'codexlens.overview.actions.ftsFull': 'FTS Full',
'codexlens.overview.actions.ftsFullDesc': 'Rebuild full-text index',
'codexlens.overview.actions.ftsIncremental': 'FTS Incremental',
'codexlens.overview.actions.ftsIncrementalDesc': 'Incremental update full-text index',
'codexlens.overview.actions.vectorFull': 'Vector Full',
'codexlens.overview.actions.vectorFullDesc': 'Rebuild vector index',
'codexlens.overview.actions.vectorIncremental': 'Vector Incremental',
'codexlens.overview.actions.vectorIncrementalDesc': 'Incremental update vector index',
'codexlens.overview.venv.title': 'Python Virtual Environment Details',
'codexlens.overview.venv.pythonVersion': 'Python Version',
'codexlens.overview.venv.venvPath': 'Virtual Environment Path',
'codexlens.overview.venv.lastCheck': 'Last Check Time',
'codexlens.settings.currentCount': 'Current Index Count',
'codexlens.settings.currentWorkers': 'Current Workers',
'codexlens.settings.currentBatchSize': 'Current Batch Size',
'codexlens.settings.configTitle': 'Basic Configuration',
'codexlens.settings.indexDir.label': 'Index Directory',
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
'codexlens.settings.indexDir.hint': 'Directory path for storing code indexes',
'codexlens.settings.maxWorkers.label': 'Max Workers',
'codexlens.settings.maxWorkers.hint': 'API concurrent processing threads (1-32)',
'codexlens.settings.batchSize.label': 'Batch Size',
'codexlens.settings.batchSize.hint': 'Number of files processed per batch (1-64)',
'codexlens.settings.validation.indexDirRequired': 'Index directory is required',
'codexlens.settings.validation.maxWorkersRange': 'Workers must be between 1 and 32',
'codexlens.settings.validation.batchSizeRange': 'Batch size must be between 1 and 64',
'codexlens.settings.save': 'Save',
'codexlens.settings.saving': 'Saving...',
'codexlens.settings.reset': 'Reset',
'codexlens.settings.saveSuccess': 'Configuration saved',
'codexlens.settings.saveFailed': 'Save failed',
'codexlens.settings.configUpdated': 'Configuration updated successfully',
'codexlens.settings.saveError': 'Error saving configuration',
'codexlens.settings.unknownError': 'An unknown error occurred',
'codexlens.models.title': 'Model Management',
'codexlens.models.searchPlaceholder': 'Search models...',
'codexlens.models.downloading': 'Downloading...',
'codexlens.models.status.downloaded': 'Downloaded',
'codexlens.models.status.available': 'Available',
'codexlens.models.types.embedding': 'Embedding Models',
'codexlens.models.types.reranker': 'Reranker Models',
'codexlens.models.filters.label': 'Filter',
'codexlens.models.filters.all': 'All',
'codexlens.models.actions.download': 'Download',
'codexlens.models.actions.delete': 'Delete',
'codexlens.models.actions.cancel': 'Cancel',
'codexlens.models.custom.title': 'Custom Model',
'codexlens.models.custom.placeholder': 'HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)',
'codexlens.models.custom.description': 'Download custom models from HuggingFace. Ensure the model name is correct.',
'codexlens.models.deleteConfirm': 'Are you sure you want to delete model {modelName}?',
'codexlens.models.notInstalled.title': 'CodexLens Not Installed',
'codexlens.models.notInstalled.description': 'Please install CodexLens to use model management features.',
'codexlens.models.empty.title': 'No models found',
'codexlens.models.empty.description': 'Try adjusting your search or filter criteria',
'navigation.codexlens': 'CodexLens',
}, },
zh: { zh: {
// Common // Common
@@ -210,6 +288,84 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'issues.discovery.status.failed': '失败', 'issues.discovery.status.failed': '失败',
'issues.discovery.progress': '进度', 'issues.discovery.progress': '进度',
'issues.discovery.findings': '发现', 'issues.discovery.findings': '发现',
// CodexLens
'codexlens.title': 'CodexLens',
'codexlens.description': '语义代码搜索引擎',
'codexlens.bootstrap': '引导安装',
'codexlens.bootstrapping': '安装中...',
'codexlens.uninstall': '卸载',
'codexlens.uninstalling': '卸载中...',
'codexlens.confirmUninstall': '确定要卸载 CodexLens 吗?',
'codexlens.notInstalled': 'CodexLens 尚未安装',
'codexlens.comingSoon': '即将推出',
'codexlens.tabs.overview': '概览',
'codexlens.tabs.settings': '设置',
'codexlens.tabs.models': '模型',
'codexlens.tabs.advanced': '高级',
'codexlens.overview.status.installation': '安装状态',
'codexlens.overview.status.ready': '就绪',
'codexlens.overview.status.notReady': '未就绪',
'codexlens.overview.status.version': '版本',
'codexlens.overview.status.indexPath': '索引路径',
'codexlens.overview.status.indexCount': '索引数量',
'codexlens.overview.notInstalled.title': 'CodexLens 未安装',
'codexlens.overview.notInstalled.message': '请先安装 CodexLens 以使用语义代码搜索功能。',
'codexlens.overview.actions.title': '快速操作',
'codexlens.overview.actions.ftsFull': 'FTS 全量',
'codexlens.overview.actions.ftsFullDesc': '重建全文索引',
'codexlens.overview.actions.ftsIncremental': 'FTS 增量',
'codexlens.overview.actions.ftsIncrementalDesc': '增量更新全文索引',
'codexlens.overview.actions.vectorFull': '向量全量',
'codexlens.overview.actions.vectorFullDesc': '重建向量索引',
'codexlens.overview.actions.vectorIncremental': '向量增量',
'codexlens.overview.actions.vectorIncrementalDesc': '增量更新向量索引',
'codexlens.overview.venv.title': 'Python 虚拟环境详情',
'codexlens.overview.venv.pythonVersion': 'Python 版本',
'codexlens.overview.venv.venvPath': '虚拟环境路径',
'codexlens.overview.venv.lastCheck': '最后检查时间',
'codexlens.settings.currentCount': '当前索引数量',
'codexlens.settings.currentWorkers': '当前工作线程',
'codexlens.settings.currentBatchSize': '当前批次大小',
'codexlens.settings.configTitle': '基本配置',
'codexlens.settings.indexDir.label': '索引目录',
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
'codexlens.settings.indexDir.hint': '存储代码索引的目录路径',
'codexlens.settings.maxWorkers.label': '最大工作线程',
'codexlens.settings.maxWorkers.hint': 'API 并发处理线程数 (1-32)',
'codexlens.settings.batchSize.label': '批次大小',
'codexlens.settings.batchSize.hint': '每次批量处理的文件数量 (1-64)',
'codexlens.settings.validation.indexDirRequired': '索引目录不能为空',
'codexlens.settings.validation.maxWorkersRange': '工作线程数必须在 1-32 之间',
'codexlens.settings.validation.batchSizeRange': '批次大小必须在 1-64 之间',
'codexlens.settings.save': '保存',
'codexlens.settings.saving': '保存中...',
'codexlens.settings.reset': '重置',
'codexlens.settings.saveSuccess': '配置已保存',
'codexlens.settings.saveFailed': '保存失败',
'codexlens.settings.configUpdated': '配置更新成功',
'codexlens.settings.saveError': '保存配置时出错',
'codexlens.settings.unknownError': '发生未知错误',
'codexlens.models.title': '模型管理',
'codexlens.models.searchPlaceholder': '搜索模型...',
'codexlens.models.downloading': '下载中...',
'codexlens.models.status.downloaded': '已下载',
'codexlens.models.status.available': '可用',
'codexlens.models.types.embedding': '嵌入模型',
'codexlens.models.types.reranker': '重排序模型',
'codexlens.models.filters.label': '筛选',
'codexlens.models.filters.all': '全部',
'codexlens.models.actions.download': '下载',
'codexlens.models.actions.delete': '删除',
'codexlens.models.actions.cancel': '取消',
'codexlens.models.custom.title': '自定义模型',
'codexlens.models.custom.placeholder': 'HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)',
'codexlens.models.custom.description': '从 HuggingFace 下载自定义模型。请确保模型名称正确。',
'codexlens.models.deleteConfirm': '确定要删除模型 {modelName} 吗?',
'codexlens.models.notInstalled.title': 'CodexLens 未安装',
'codexlens.models.notInstalled.description': '请先安装 CodexLens 以使用模型管理功能。',
'codexlens.models.empty.title': '没有找到模型',
'codexlens.models.empty.description': '尝试调整搜索或筛选条件',
'navigation.codexlens': 'CodexLens',
}, },
}; };

View File

@@ -0,0 +1,445 @@
// ========================================
// E2E Tests: CodexLens Manager
// ========================================
// End-to-end tests for CodexLens management feature
import { test, expect } from '@playwright/test';
import { setupEnhancedMonitoring } from './helpers/i18n-helpers';
test.describe('[CodexLens Manager] - CodexLens Management Tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' as const });
});
test('L4.1 - should navigate to CodexLens manager', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
// Navigate to CodexLens page
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Check page title
const title = page.getByText(/CodexLens/i).or(page.getByRole('heading', { name: /CodexLens/i }));
await expect(title).toBeVisible({ timeout: 5000 }).catch(() => false);
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.2 - should display all tabs', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Check for tabs
const tabs = ['Overview', 'Settings', 'Models', 'Advanced'];
for (const tab of tabs) {
const tabElement = page.getByRole('tab', { name: new RegExp(tab, 'i') });
const isVisible = await tabElement.isVisible().catch(() => false);
if (isVisible) {
await expect(tabElement).toBeVisible();
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.3 - should switch between tabs', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Click Settings tab
const settingsTab = page.getByRole('tab', { name: /Settings/i });
const settingsVisible = await settingsTab.isVisible().catch(() => false);
if (settingsVisible) {
await settingsTab.click();
// Verify tab is active
await expect(settingsTab).toHaveAttribute('data-state', 'active');
}
// Click Models tab
const modelsTab = page.getByRole('tab', { name: /Models/i });
const modelsVisible = await modelsTab.isVisible().catch(() => false);
if (modelsVisible) {
await modelsTab.click();
await expect(modelsTab).toHaveAttribute('data-state', 'active');
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.4 - should display overview status cards when installed', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Look for status cards
const statusLabels = ['Installation Status', 'Version', 'Index Path', 'Index Count'];
for (const label of statusLabels) {
const element = page.getByText(new RegExp(label, 'i'));
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
await expect(element).toBeVisible();
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.5 - should display quick action buttons', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Look for quick action buttons
const actions = ['FTS Full', 'FTS Incremental', 'Vector Full', 'Vector Incremental'];
for (const action of actions) {
const button = page.getByRole('button', { name: new RegExp(action, 'i') });
const isVisible = await button.isVisible().catch(() => false);
if (isVisible) {
await expect(button).toBeVisible();
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.6 - should display settings form', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Switch to Settings tab
const settingsTab = page.getByRole('tab', { name: /Settings/i });
const settingsVisible = await settingsTab.isVisible().catch(() => false);
if (settingsVisible) {
await settingsTab.click();
// Check for form inputs
const indexDirInput = page.getByLabel(/Index Directory/i);
const maxWorkersInput = page.getByLabel(/Max Workers/i);
const batchSizeInput = page.getByLabel(/Batch Size/i);
const indexDirVisible = await indexDirInput.isVisible().catch(() => false);
const maxWorkersVisible = await maxWorkersInput.isVisible().catch(() => false);
const batchSizeVisible = await batchSizeInput.isVisible().catch(() => false);
// At least one should be visible if the form is rendered
expect(indexDirVisible || maxWorkersVisible || batchSizeVisible).toBe(true);
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.7 - should save settings configuration', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
const settingsTab = page.getByRole('tab', { name: /Settings/i });
const settingsVisible = await settingsTab.isVisible().catch(() => false);
if (settingsVisible) {
await settingsTab.click();
// Modify index directory
const indexDirInput = page.getByLabel(/Index Directory/i);
const indexDirVisible = await indexDirInput.isVisible().catch(() => false);
if (indexDirVisible) {
await indexDirInput.fill('/custom/index/path');
// Click save button
const saveButton = page.getByRole('button', { name: /Save/i });
const saveVisible = await saveButton.isVisible().catch(() => false);
if (saveVisible && !(await saveButton.isDisabled())) {
await saveButton.click();
// Wait for success or completion
await page.waitForTimeout(1000);
}
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.8 - should validate settings form', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
const settingsTab = page.getByRole('tab', { name: /Settings/i });
const settingsVisible = await settingsTab.isVisible().catch(() => false);
if (settingsVisible) {
await settingsTab.click();
// Try to save with empty index directory
const indexDirInput = page.getByLabel(/Index Directory/i);
const indexDirVisible = await indexDirInput.isVisible().catch(() => false);
if (indexDirVisible) {
await indexDirInput.fill('');
const saveButton = page.getByRole('button', { name: /Save/i });
const saveVisible = await saveButton.isVisible().catch(() => false);
if (saveVisible && !(await saveButton.isDisabled())) {
await saveButton.click();
// Check for validation error
const errorMessage = page.getByText(/required/i, { exact: false });
const hasError = await errorMessage.isVisible().catch(() => false);
if (hasError) {
await expect(errorMessage).toBeVisible();
}
}
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.9 - should display models list', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Switch to Models tab
const modelsTab = page.getByRole('tab', { name: /Models/i });
const modelsVisible = await modelsTab.isVisible().catch(() => false);
if (modelsVisible) {
await modelsTab.click();
// Look for filter buttons
const filters = ['All', 'Embedding', 'Reranker', 'Downloaded', 'Available'];
for (const filter of filters) {
const button = page.getByRole('button', { name: new RegExp(filter, 'i') });
const isVisible = await button.isVisible().catch(() => false);
if (isVisible) {
await expect(button).toBeVisible();
}
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.10 - should filter models by type', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
const modelsTab = page.getByRole('tab', { name: /Models/i });
const modelsVisible = await modelsTab.isVisible().catch(() => false);
if (modelsVisible) {
await modelsTab.click();
// Click Embedding filter
const embeddingFilter = page.getByRole('button', { name: /Embedding/i });
const embeddingVisible = await embeddingFilter.isVisible().catch(() => false);
if (embeddingVisible) {
await embeddingFilter.click();
// Filter should be active
await expect(embeddingFilter).toHaveAttribute('data-state', 'active');
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.11 - should search models', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
const modelsTab = page.getByRole('tab', { name: /Models/i });
const modelsVisible = await modelsTab.isVisible().catch(() => false);
if (modelsVisible) {
await modelsTab.click();
// Type in search box
const searchInput = page.getByPlaceholderText(/Search models/i);
const searchVisible = await searchInput.isVisible().catch(() => false);
if (searchVisible) {
await searchInput.fill('test-model');
await page.waitForTimeout(500);
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.12 - should handle bootstrap when not installed', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Look for bootstrap button (only visible when not installed)
const bootstrapButton = page.getByRole('button', { name: /Bootstrap/i });
const bootstrapVisible = await bootstrapButton.isVisible().catch(() => false);
if (bootstrapVisible) {
await expect(bootstrapButton).toBeVisible();
// Don't actually click it to avoid installing in test
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.13 - should show uninstall confirmation', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Look for uninstall button (only visible when installed)
const uninstallButton = page.getByRole('button', { name: /Uninstall/i });
const uninstallVisible = await uninstallButton.isVisible().catch(() => false);
if (uninstallVisible) {
// Set up dialog handler before clicking
page.on('dialog', async (dialog) => {
await dialog.dismiss();
});
await uninstallButton.click();
// Check for confirmation dialog
const dialog = page.getByRole('dialog');
const dialogVisible = await dialog.isVisible().catch(() => false);
// Dialog may or may not appear depending on implementation
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.14 - should display refresh button', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Look for refresh button
const refreshButton = page.getByRole('button', { name: /Refresh/i }).or(
page.getByRole('button', { name: /refresh/i })
);
const refreshVisible = await refreshButton.isVisible().catch(() => false);
if (refreshVisible) {
await expect(refreshButton).toBeVisible();
// Click refresh
await refreshButton.click();
await page.waitForTimeout(500);
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.15 - should handle API errors gracefully', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
// Mock API failure for CodexLens endpoint
await page.route('**/api/codexlens/**', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Look for error indicator or graceful degradation
const title = page.getByText(/CodexLens/i);
const titleVisible = await title.isVisible().catch(() => false);
// Restore routing
await page.unroute('**/api/codexlens/**');
// Page should still be visible despite error
expect(titleVisible).toBe(true);
monitoring.assertClean({ ignoreAPIPatterns: ['/api/codexlens'], allowWarnings: true });
monitoring.stop();
});
test('L4.16 - should switch language and verify translations', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
// Switch to Chinese if language switcher is available
const languageSwitcher = page.getByRole('button', { name: /中文|Language/i });
const switcherVisible = await languageSwitcher.isVisible().catch(() => false);
if (switcherVisible) {
await languageSwitcher.click();
// Check for Chinese translations
const chineseTitle = page.getByText(/CodexLens/i);
await expect(chineseTitle).toBeVisible();
// Check for Chinese tab labels
const overviewTab = page.getByRole('tab', { name: /概览/i });
const overviewVisible = await overviewTab.isVisible().catch(() => false);
if (overviewVisible) {
await expect(overviewTab).toBeVisible();
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.17 - should navigate from sidebar', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/', { waitUntil: 'networkidle' as const });
// Look for CodexLens link in sidebar
const codexLensLink = page.getByRole('link', { name: /CodexLens/i });
const linkVisible = await codexLensLink.isVisible().catch(() => false);
if (linkVisible) {
await codexLensLink.click();
await page.waitForURL(/codexlens/);
expect(page.url()).toContain('codexlens');
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
test('L4.18 - should display empty state when no models', async ({ page }) => {
const monitoring = setupEnhancedMonitoring(page);
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
const modelsTab = page.getByRole('tab', { name: /Models/i });
const modelsVisible = await modelsTab.isVisible().catch(() => false);
if (modelsVisible) {
await modelsTab.click();
// Search for a non-existent model to show empty state
const searchInput = page.getByPlaceholderText(/Search models/i);
const searchVisible = await searchInput.isVisible().catch(() => false);
if (searchVisible) {
await searchInput.fill('nonexistent-model-xyz-123');
// Look for empty state message
const emptyState = page.getByText(/No models found/i);
const emptyVisible = await emptyState.isVisible().catch(() => false);
if (emptyVisible) {
await expect(emptyState).toBeVisible();
}
}
}
monitoring.assertClean({ allowWarnings: true });
monitoring.stop();
});
});

View File

@@ -13,6 +13,7 @@ interface HookOptions {
sessionId?: string; sessionId?: string;
prompt?: string; prompt?: string;
type?: 'session-start' | 'context'; type?: 'session-start' | 'context';
path?: string;
} }
interface HookData { interface HookData {
@@ -209,6 +210,59 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
} }
} }
/**
* Parse CCW status.json and output formatted status
*/
async function parseStatusAction(options: HookOptions): Promise<void> {
const { path: filePath } = options;
if (!filePath) {
console.error(chalk.red('Error: --path is required'));
process.exit(1);
}
try {
// Check if this is a CCW status.json file
if (!filePath.includes('status.json') ||
!filePath.match(/\.(ccw|ccw-coordinator|ccw-debug)[/\\]/)) {
console.log(chalk.gray('(Not a CCW status file)'));
process.exit(0);
}
// Read and parse status.json
if (!existsSync(filePath)) {
console.log(chalk.gray('(Status file not found)'));
process.exit(0);
}
const statusContent = readFileSync(filePath, 'utf8');
const status = JSON.parse(statusContent);
// Extract key information
const sessionId = status.session_id || 'unknown';
const workflow = status.workflow || status.mode || 'unknown';
// Find current command (running or last completed)
let currentCommand = status.command_chain?.find((cmd: { status: string }) => cmd.status === 'running')?.command;
if (!currentCommand) {
const completed = status.command_chain?.filter((cmd: { status: string }) => cmd.status === 'completed');
currentCommand = completed?.[completed.length - 1]?.command || 'unknown';
}
// Find next command (first pending)
const nextCommand = status.command_chain?.find((cmd: { status: string }) => cmd.status === 'pending')?.command || '无';
// Format status message
const message = `📋 CCW Status [${sessionId}] (${workflow}): 当前处于 ${currentCommand},下一个命令 ${nextCommand}`;
console.log(message);
process.exit(0);
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/** /**
* Notify dashboard action - send notification to running ccw view server * Notify dashboard action - send notification to running ccw view server
*/ */
@@ -255,15 +309,20 @@ ${chalk.bold('USAGE')}
ccw hook <subcommand> [options] ccw hook <subcommand> [options]
${chalk.bold('SUBCOMMANDS')} ${chalk.bold('SUBCOMMANDS')}
parse-status Parse CCW status.json and display current/next command
session-context Progressive session context loading (replaces curl/bash hook) session-context Progressive session context loading (replaces curl/bash hook)
notify Send notification to ccw view dashboard notify Send notification to ccw view dashboard
${chalk.bold('OPTIONS')} ${chalk.bold('OPTIONS')}
--stdin Read input from stdin (for Claude Code hooks) --stdin Read input from stdin (for Claude Code hooks)
--path Path to status.json file (for parse-status)
--session-id Session ID (alternative to stdin) --session-id Session ID (alternative to stdin)
--prompt Current prompt text (alternative to stdin) --prompt Current prompt text (alternative to stdin)
${chalk.bold('EXAMPLES')} ${chalk.bold('EXAMPLES')}
${chalk.gray('# Parse CCW status file:')}
ccw hook parse-status --path .workflow/.ccw/ccw-123/status.json
${chalk.gray('# Use in Claude Code hook (settings.json):')} ${chalk.gray('# Use in Claude Code hook (settings.json):')}
ccw hook session-context --stdin ccw hook session-context --stdin
@@ -274,14 +333,14 @@ ${chalk.bold('EXAMPLES')}
ccw hook notify --stdin ccw hook notify --stdin
${chalk.bold('HOOK CONFIGURATION')} ${chalk.bold('HOOK CONFIGURATION')}
${chalk.gray('Add to .claude/settings.json:')} ${chalk.gray('Add to .claude/settings.json for status tracking:')}
{ {
"hooks": { "hooks": {
"UserPromptSubmit": [{ "PostToolUse": [{
"hooks": [{ "trigger": "PostToolUse",
"type": "command", "matcher": "Write",
"command": "ccw hook session-context --stdin" "command": "bash",
}] "args": ["-c", "INPUT=$(cat); FILE_PATH=$(echo \\"$INPUT\\" | jq -r \\".tool_input.file_path // empty\\"); [ -n \\"$FILE_PATH\\" ] && ccw hook parse-status --path \\"$FILE_PATH\\""]
}] }]
} }
} }
@@ -297,6 +356,9 @@ export async function hookCommand(
options: HookOptions options: HookOptions
): Promise<void> { ): Promise<void> {
switch (subcommand) { switch (subcommand) {
case 'parse-status':
await parseStatusAction(options);
break;
case 'session-context': case 'session-context':
case 'context': case 'context':
await sessionContextAction(options); await sessionContextAction(options);

View File

@@ -483,6 +483,35 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
} }
// Handle GET request - return conversation with native session info // Handle GET request - return conversation with native session info
// First check in-memory active executions (for running/recently completed)
const activeExec = activeExecutions.get(executionId);
if (activeExec) {
// Return active execution data as conversation record format
const activeConversation = {
id: activeExec.id,
tool: activeExec.tool,
mode: activeExec.mode,
created_at: new Date(activeExec.startTime).toISOString(),
turn_count: 1,
turns: [{
turn: 1,
timestamp: new Date(activeExec.startTime).toISOString(),
prompt: activeExec.prompt,
output: { stdout: activeExec.output, stderr: '' },
duration_ms: activeExec.completedTimestamp
? activeExec.completedTimestamp - activeExec.startTime
: Date.now() - activeExec.startTime
}],
// Active execution flag for frontend to handle appropriately
_active: true,
_status: activeExec.status
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(activeConversation));
return true;
}
// Fall back to database query for saved conversations
const conversation = getConversationDetailWithNativeInfo(projectPath, executionId); const conversation = getConversationDetailWithNativeInfo(projectPath, executionId);
if (!conversation) { if (!conversation) {
res.writeHead(404, { 'Content-Type': 'application/json' }); res.writeHead(404, { 'Content-Type': 'application/json' });

View File

@@ -412,6 +412,116 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
return true; return true;
} }
// API: Execute CCW CLI command and parse status
if (pathname === '/api/hook/ccw-exec' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
if (typeof body !== 'object' || body === null) {
return { error: 'Invalid request body', status: 400 };
}
const { filePath, command = 'parse-status' } = body as { filePath?: unknown; command?: unknown };
if (typeof filePath !== 'string') {
return { error: 'filePath is required', status: 400 };
}
// Check if this is a CCW status.json file
if (!filePath.includes('status.json') ||
!filePath.match(/\.(ccw|ccw-coordinator|ccw-debug)\//)) {
return { success: false, message: 'Not a CCW status file' };
}
try {
// Execute CCW CLI command to parse status
const result = await executeCliCommand('ccw', ['hook', 'parse-status', filePath]);
if (result.success) {
const parsed = JSON.parse(result.output);
return {
success: true,
...parsed
};
} else {
return {
success: false,
error: result.error
};
}
} catch (error) {
console.error('[Hooks] Failed to execute CCW command:', error);
return {
success: false,
error: (error as Error).message
};
}
});
return true;
}
// API: Parse CCW status.json and return formatted status (fallback)
if (pathname === '/api/hook/ccw-status' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
if (typeof body !== 'object' || body === null) {
return { error: 'Invalid request body', status: 400 };
}
const { filePath } = body as { filePath?: unknown };
if (typeof filePath !== 'string') {
return { error: 'filePath is required', status: 400 };
}
// Check if this is a CCW status.json file
if (!filePath.includes('status.json') ||
!filePath.match(/\.(ccw|ccw-coordinator|ccw-debug)\//)) {
return { success: false, message: 'Not a CCW status file' };
}
try {
// Read and parse status.json
if (!existsSync(filePath)) {
return { success: false, message: 'Status file not found' };
}
const statusContent = readFileSync(filePath, 'utf8');
const status = JSON.parse(statusContent);
// Extract key information
const sessionId = status.session_id || 'unknown';
const workflow = status.workflow || status.mode || 'unknown';
// Find current command (running or last completed)
let currentCommand = status.command_chain?.find((cmd: { status: string }) => cmd.status === 'running')?.command;
if (!currentCommand) {
const completed = status.command_chain?.filter((cmd: { status: string }) => cmd.status === 'completed');
currentCommand = completed?.[completed.length - 1]?.command || 'unknown';
}
// Find next command (first pending)
const nextCommand = status.command_chain?.find((cmd: { status: string }) => cmd.status === 'pending')?.command || '无';
// Format status message
const message = `📋 CCW Status [${sessionId}] (${workflow}): 当前处于 ${currentCommand},下一个命令 ${nextCommand}`;
return {
success: true,
message,
sessionId,
workflow,
currentCommand,
nextCommand
};
} catch (error) {
console.error('[Hooks] Failed to parse CCW status:', error);
return {
success: false,
error: (error as Error).message
};
}
});
return true;
}
// API: Get hooks configuration // API: Get hooks configuration
if (pathname === '/api/hooks' && req.method === 'GET') { if (pathname === '/api/hooks' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path'); const projectPathParam = url.searchParams.get('path');
@@ -471,3 +581,63 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
return false; return false;
} }
// ========================================
// Helper: Execute CLI Command
// ========================================
/**
* Execute a CLI command and capture output
* @param {string} command - Command name (e.g., 'ccw', 'npx')
* @param {string[]} args - Command arguments
* @returns {Promise<{success: boolean; output: string; error?: string}>}
*/
async function executeCliCommand(
command: string,
args: string[]
): Promise<{ success: boolean; output: string; error?: string }> {
return new Promise((resolve) => {
let output = '';
let errorOutput = '';
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 30000 // 30 second timeout
});
if (child.stdout) {
child.stdout.on('data', (data) => {
output += data.toString();
});
}
if (child.stderr) {
child.stderr.on('data', (data) => {
errorOutput += data.toString();
});
}
child.on('close', (code) => {
if (code === 0) {
resolve({
success: true,
output: output.trim()
});
} else {
resolve({
success: false,
output: output.trim(),
error: errorOutput.trim() || `Command failed with exit code ${code}`
});
}
});
child.on('error', (err) => {
resolve({
success: false,
output: '',
error: (err as Error).message
});
});
});
}