mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-29 20:11:04 +08:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
292
ccw/frontend/src/components/codexlens/AdvancedTab.tsx
Normal file
292
ccw/frontend/src/components/codexlens/AdvancedTab.tsx
Normal 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;
|
||||||
293
ccw/frontend/src/components/codexlens/GpuSelector.tsx
Normal file
293
ccw/frontend/src/components/codexlens/GpuSelector.tsx
Normal 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;
|
||||||
231
ccw/frontend/src/components/codexlens/ModelCard.tsx
Normal file
231
ccw/frontend/src/components/codexlens/ModelCard.tsx
Normal 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;
|
||||||
396
ccw/frontend/src/components/codexlens/ModelsTab.test.tsx
Normal file
396
ccw/frontend/src/components/codexlens/ModelsTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
283
ccw/frontend/src/components/codexlens/ModelsTab.tsx
Normal file
283
ccw/frontend/src/components/codexlens/ModelsTab.tsx
Normal 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;
|
||||||
280
ccw/frontend/src/components/codexlens/OverviewTab.test.tsx
Normal file
280
ccw/frontend/src/components/codexlens/OverviewTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
246
ccw/frontend/src/components/codexlens/OverviewTab.tsx
Normal file
246
ccw/frontend/src/components/codexlens/OverviewTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
456
ccw/frontend/src/components/codexlens/SettingsTab.test.tsx
Normal file
456
ccw/frontend/src/components/codexlens/SettingsTab.test.tsx
Normal 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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
272
ccw/frontend/src/components/codexlens/SettingsTab.tsx
Normal file
272
ccw/frontend/src/components/codexlens/SettingsTab.tsx
Normal 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;
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
238
ccw/frontend/src/components/issue/hub/IssueDrawer.tsx
Normal file
238
ccw/frontend/src/components/issue/hub/IssueDrawer.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
212
ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx
Normal file
212
ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx
Normal 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;
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
ccw/frontend/src/components/session-detail/context/index.ts
Normal file
24
ccw/frontend/src/components/session-detail/context/index.ts
Normal 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';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ccw/frontend/src/components/session-detail/tasks/index.ts
Normal file
12
ccw/frontend/src/components/session-detail/tasks/index.ts
Normal 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';
|
||||||
245
ccw/frontend/src/components/shared/MarkdownModal.tsx
Normal file
245
ccw/frontend/src/components/shared/MarkdownModal.tsx
Normal 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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
427
ccw/frontend/src/hooks/useCodexLens.test.tsx
Normal file
427
ccw/frontend/src/hooks/useCodexLens.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
762
ccw/frontend/src/hooks/useCodexLens.ts
Normal file
762
ccw/frontend/src/hooks/useCodexLens.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
178
ccw/frontend/src/locales/en/codexlens.json
Normal file
178
ccw/frontend/src/locales/en/codexlens.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 面板发送通知"
|
||||||
|
|||||||
178
ccw/frontend/src/locales/zh/codexlens.json
Normal file
178
ccw/frontend/src/locales/zh/codexlens.json
Normal 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": "尝试调整搜索或筛选条件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "活跃",
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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": "发现",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"prompts": "提示历史",
|
"prompts": "提示历史",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"mcp": "MCP 服务器",
|
"mcp": "MCP 服务器",
|
||||||
|
"codexlens": "CodexLens",
|
||||||
"endpoints": "CLI 端点",
|
"endpoints": "CLI 端点",
|
||||||
"installations": "安装",
|
"installations": "安装",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
|
|||||||
@@ -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": "更新时间",
|
||||||
|
|||||||
364
ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
Normal file
364
ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
205
ccw/frontend/src/pages/CodexLensManagerPage.tsx
Normal file
205
ccw/frontend/src/pages/CodexLensManagerPage.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) */}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
176
ccw/frontend/src/pages/session-detail/ConflictTab.tsx
Normal file
176
ccw/frontend/src/pages/session-detail/ConflictTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
113
ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
Normal file
113
ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
Normal 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;
|
||||||
227
ccw/frontend/src/pages/session-detail/ReviewTab.tsx
Normal file
227
ccw/frontend/src/pages/session-detail/ReviewTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
445
ccw/frontend/tests/e2e/codexlens-manager.spec.ts
Normal file
445
ccw/frontend/tests/e2e/codexlens-manager.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user