From d46406df4a2604326e75211d18687ae791c28789 Mon Sep 17 00:00:00 2001
From: catlog22
Date: Sun, 1 Feb 2026 17:45:38 +0800
Subject: [PATCH] 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
---
.claude/commands/ccw-debug.md | 104 +-
.claude/commands/ccw.md | 147 +-
.../commands/workflow/analyze-with-file.md | 1023 +++++--------
.../commands/workflow/brainstorm-with-file.md | 1349 +++++++----------
.codex/prompts/merge-plans-with-file.md | 530 -------
.../src/components/codexlens/AdvancedTab.tsx | 292 ++++
.../src/components/codexlens/GpuSelector.tsx | 293 ++++
.../src/components/codexlens/ModelCard.tsx | 231 +++
.../components/codexlens/ModelsTab.test.tsx | 396 +++++
.../src/components/codexlens/ModelsTab.tsx | 283 ++++
.../components/codexlens/OverviewTab.test.tsx | 280 ++++
.../src/components/codexlens/OverviewTab.tsx | 246 +++
.../components/codexlens/SettingsTab.test.tsx | 456 ++++++
.../src/components/codexlens/SettingsTab.tsx | 272 ++++
.../components/hook/HookQuickTemplates.tsx | 13 +
.../issue/discovery/DiscoveryDetail.tsx | 74 +-
.../issue/discovery/FindingList.tsx | 236 ++-
.../components/issue/hub/DiscoveryPanel.tsx | 13 +-
.../src/components/issue/hub/IssueDrawer.tsx | 238 +++
.../src/components/issue/hub/IssuesPanel.tsx | 148 +-
.../src/components/issue/hub/QueuePanel.tsx | 49 +-
.../components/issue/queue/ExecutionGroup.tsx | 58 +-
.../components/issue/queue/QueueActions.tsx | 288 +++-
.../src/components/issue/queue/QueueCard.tsx | 9 +
.../components/issue/queue/SolutionDrawer.tsx | 212 +++
.../src/components/layout/Sidebar.tsx | 6 +-
.../session-detail/context/AssetsCard.tsx | 143 ++
.../context/ConflictDetectionCard.tsx | 159 ++
.../context/DependenciesCard.tsx | 146 ++
.../context/ExplorationCollapsible.tsx | 56 +
.../context/ExplorationsSection.tsx | 183 +++
.../session-detail/context/FieldRenderer.tsx | 143 ++
.../context/TestContextCard.tsx | 179 +++
.../session-detail/context/index.ts | 24 +
.../session-detail/tasks/BulkActionButton.tsx | 46 +
.../session-detail/tasks/TaskStatsBar.tsx | 94 ++
.../tasks/TaskStatusDropdown.tsx | 134 ++
.../components/session-detail/tasks/index.ts | 12 +
.../src/components/shared/MarkdownModal.tsx | 245 +++
ccw/frontend/src/components/ui/index.ts | 3 +
ccw/frontend/src/hooks/index.ts | 53 +-
ccw/frontend/src/hooks/useCli.ts | 4 +-
ccw/frontend/src/hooks/useCodexLens.test.tsx | 427 ++++++
ccw/frontend/src/hooks/useCodexLens.ts | 762 ++++++++++
ccw/frontend/src/hooks/useCommands.ts | 3 +-
ccw/frontend/src/hooks/useIssues.ts | 51 +-
ccw/frontend/src/hooks/useSkills.ts | 5 +-
ccw/frontend/src/lib/api.ts | 758 ++++++++-
ccw/frontend/src/locales/en/cli-hooks.json | 4 +
ccw/frontend/src/locales/en/codexlens.json | 178 +++
ccw/frontend/src/locales/en/index.ts | 4 +-
ccw/frontend/src/locales/en/issues.json | 42 +-
ccw/frontend/src/locales/en/navigation.json | 1 +
.../src/locales/en/session-detail.json | 113 +-
ccw/frontend/src/locales/zh/cli-hooks.json | 4 +
ccw/frontend/src/locales/zh/codexlens.json | 178 +++
ccw/frontend/src/locales/zh/common.json | 8 +-
ccw/frontend/src/locales/zh/index.ts | 4 +-
ccw/frontend/src/locales/zh/issues.json | 150 +-
ccw/frontend/src/locales/zh/navigation.json | 1 +
.../src/locales/zh/session-detail.json | 111 +-
.../src/pages/CodexLensManagerPage.test.tsx | 364 +++++
.../src/pages/CodexLensManagerPage.tsx | 205 +++
ccw/frontend/src/pages/DiscoveryPage.tsx | 4 +
ccw/frontend/src/pages/IssueHubPage.tsx | 195 ++-
ccw/frontend/src/pages/SessionDetailPage.tsx | 43 +-
ccw/frontend/src/pages/index.ts | 1 +
.../src/pages/session-detail/ConflictTab.tsx | 176 +++
.../src/pages/session-detail/ContextTab.tsx | 39 +-
.../src/pages/session-detail/ImplPlanTab.tsx | 113 ++
.../src/pages/session-detail/ReviewTab.tsx | 227 +++
.../src/pages/session-detail/SummaryTab.tsx | 152 +-
.../src/pages/session-detail/TaskListTab.tsx | 201 ++-
ccw/frontend/src/router.tsx | 6 +
ccw/frontend/src/test/i18n.tsx | 156 ++
.../tests/e2e/codexlens-manager.spec.ts | 445 ++++++
ccw/src/commands/hook.ts | 74 +-
ccw/src/core/routes/cli-routes.ts | 29 +
ccw/src/core/routes/hooks-routes.ts | 170 +++
79 files changed, 11819 insertions(+), 2455 deletions(-)
delete mode 100644 .codex/prompts/merge-plans-with-file.md
create mode 100644 ccw/frontend/src/components/codexlens/AdvancedTab.tsx
create mode 100644 ccw/frontend/src/components/codexlens/GpuSelector.tsx
create mode 100644 ccw/frontend/src/components/codexlens/ModelCard.tsx
create mode 100644 ccw/frontend/src/components/codexlens/ModelsTab.test.tsx
create mode 100644 ccw/frontend/src/components/codexlens/ModelsTab.tsx
create mode 100644 ccw/frontend/src/components/codexlens/OverviewTab.test.tsx
create mode 100644 ccw/frontend/src/components/codexlens/OverviewTab.tsx
create mode 100644 ccw/frontend/src/components/codexlens/SettingsTab.test.tsx
create mode 100644 ccw/frontend/src/components/codexlens/SettingsTab.tsx
create mode 100644 ccw/frontend/src/components/issue/hub/IssueDrawer.tsx
create mode 100644 ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/AssetsCard.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/ConflictDetectionCard.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/ExplorationCollapsible.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/ExplorationsSection.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/FieldRenderer.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/TestContextCard.tsx
create mode 100644 ccw/frontend/src/components/session-detail/context/index.ts
create mode 100644 ccw/frontend/src/components/session-detail/tasks/BulkActionButton.tsx
create mode 100644 ccw/frontend/src/components/session-detail/tasks/TaskStatsBar.tsx
create mode 100644 ccw/frontend/src/components/session-detail/tasks/TaskStatusDropdown.tsx
create mode 100644 ccw/frontend/src/components/session-detail/tasks/index.ts
create mode 100644 ccw/frontend/src/components/shared/MarkdownModal.tsx
create mode 100644 ccw/frontend/src/hooks/useCodexLens.test.tsx
create mode 100644 ccw/frontend/src/hooks/useCodexLens.ts
create mode 100644 ccw/frontend/src/locales/en/codexlens.json
create mode 100644 ccw/frontend/src/locales/zh/codexlens.json
create mode 100644 ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
create mode 100644 ccw/frontend/src/pages/CodexLensManagerPage.tsx
create mode 100644 ccw/frontend/src/pages/session-detail/ConflictTab.tsx
create mode 100644 ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
create mode 100644 ccw/frontend/src/pages/session-detail/ReviewTab.tsx
create mode 100644 ccw/frontend/tests/e2e/codexlens-manager.spec.ts
diff --git a/.claude/commands/ccw-debug.md b/.claude/commands/ccw-debug.md
index 0dc418a2..241b9882 100644
--- a/.claude/commands/ccw-debug.md
+++ b/.claude/commands/ccw-debug.md
@@ -148,6 +148,8 @@ User Input โ Quick Context Gather โ ccw cli (Gemini/Qwen/Codex)
// - Read error file if path provided
// - Extract error patterns from description
// - Identify likely affected files (basic grep)
+
+ // Note: CLI mode does not generate status.json (lightweight)
```
2. **Execute CLI Analysis** (Phase 3)
@@ -299,6 +301,27 @@ User Input โ Session Init โ /workflow:debug-with-file
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: "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)
@@ -373,12 +396,38 @@ User Input โ Session Init โ /workflow:test-fix-gen
1. **Session Initialization** (Phase 2)
```javascript
+ const sessionId = `CCWD-${bugSlug}-${dateStr}`
+ const sessionFolder = `.workflow/.ccw-debug/${sessionId}`
+ bash(`mkdir -p ${sessionFolder}`)
+
const modeConfig = {
mode: "test",
original_input: bug_description,
timestamp: getUtc8ISOString(),
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)
@@ -439,8 +488,32 @@ User Input โ Session Init โ Parallel execution:
**Execution Steps**:
-1. **Parallel Execution** (Phase 3)
+1. **Session Initialization & Parallel Execution** (Phase 2-3)
```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
const debugTask = Skill(skill="workflow:debug-with-file", args=`"${bug_description}"`)
@@ -579,18 +652,24 @@ Arguments:
### Session State Management
+**Status JSON Location**: `.workflow/.ccw-debug/{session_id}/status.json`
+
+**Status JSON Structure**:
```json
{
"session_id": "CCWD-login-timeout-2025-01-27",
- "mode": "debug|test|bidirectional",
+ "mode": "debug|test|bidirectional|cli",
"status": "running|completed|failed|paused",
- "phases": {
- "phase_1": { "status": "completed", "timestamp": "..." },
- "phase_2": { "status": "in_progress", "timestamp": "..." },
- "phase_3": { "status": "pending" },
- "phase_4": { "status": "pending" },
- "phase_5": { "status": "pending" }
- },
+ "created_at": "2025-01-27T10:30:00Z",
+ "updated_at": "2025-01-27T10:35:00Z",
+ "bug_description": "User login timeout after 30 seconds",
+ "command_chain": [
+ { "index": 0, "command": "Phase 1: Debug & Analysis", "status": "completed" },
+ { "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": {
"debug_session": "DBG-...",
"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
diff --git a/.claude/commands/ccw.md b/.claude/commands/ccw.md
index 6d7564f8..fdb8aad2 100644
--- a/.claude/commands/ccw.md
+++ b/.claude/commands/ccw.md
@@ -327,49 +327,107 @@ async function getUserConfirmation(chain) {
---
-### Phase 4: Setup TODO Tracking
+### Phase 4: Setup TODO Tracking & Status File
```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) => ({
content: `CCW:${workflow}: [${i + 1}/${chain.length}] ${step.cmd}`,
status: i === 0 ? 'in_progress' : 'pending',
activeForm: `Executing ${step.cmd}`
}));
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
```javascript
-async function executeCommandChain(chain, workflow) {
+async function executeCommandChain(chain, workflow, trackingState) {
let previousResult = null;
+ const { sessionId, stateDir, state } = trackingState;
for (let i = 0; i < chain.length; i++) {
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 result = await Skill({ skill: fullCommand });
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');
} 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);
if (action === 'retry') {
+ state.command_chain[i].status = 'pending';
+ state.status = 'running';
i--; // Retry
} else if (action === 'abort') {
+ state.status = 'failed';
+ Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
return { success: false, error: error.message };
}
// '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
@@ -434,16 +492,19 @@ Phase 3: User Confirmation (optional)
|-- Show pipeline visualization
+-- Allow adjustment
|
-Phase 4: Setup TODO Tracking
- +-- Create todos with CCW prefix
+Phase 4: Setup TODO Tracking & Status File
+ |-- Create todos with CCW prefix
+ +-- Initialize .workflow/.ccw/{session_id}/status.json
|
Phase 5: Execute Command Chain
|-- For each command:
+ | |-- Update status.json (current=running)
| |-- Assemble full command
| |-- Execute via Skill
+ | |-- Update status.json (current=completed, next=running)
| |-- Update TODO status
| +-- Handle errors (retry/skip/abort)
- +-- Return workflow result
+ +-- Mark status.json as completed
```
---
@@ -482,7 +543,9 @@ Phase 5: Execute Command Chain
## 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
// 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
```bash
diff --git a/.claude/commands/workflow/analyze-with-file.md b/.claude/commands/workflow/analyze-with-file.md
index f51be9dc..68a3eb3a 100644
--- a/.claude/commands/workflow/analyze-with-file.md
+++ b/.claude/commands/workflow/analyze-with-file.md
@@ -9,256 +9,190 @@ allowed-tools: TodoWrite(*), Task(*), AskUserQuestion(*), Read(*), Grep(*), Glob
When `--yes` or `-y`: Auto-confirm exploration decisions, use recommended analysis angles.
-# Workflow Analyze-With-File Command (/workflow:analyze-with-file)
+# Workflow Analyze Command
+
+## Quick Start
+
+```bash
+# Basic usage
+/workflow:analyze-with-file "ๅฆไฝไผๅ่ฟไธช้กน็ฎ็่ฎค่ฏๆถๆ"
+
+# With options
+/workflow:analyze-with-file --continue "่ฎค่ฏๆถๆ" # Continue existing session
+/workflow:analyze-with-file -y "ๆง่ฝ็ถ้ขๅๆ" # Auto mode
+```
+
+**Context Source**: cli-explore-agent + Gemini/Codex analysis
+**Output Directory**: `.workflow/.analysis/{session-id}/`
+**Core Innovation**: Documented discussion timeline with evolving understanding
+
+## Output Artifacts
+
+### Phase 1: Topic Understanding
+
+| Artifact | Description |
+|----------|-------------|
+| `discussion.md` | Evolution of understanding & discussions (initialized) |
+| Session variables | Dimensions, focus areas, analysis depth |
+
+### Phase 2: CLI Exploration
+
+| Artifact | Description |
+|----------|-------------|
+| `exploration-codebase.json` | Codebase context from cli-explore-agent |
+| `explorations.json` | Aggregated CLI findings (Gemini/Codex) |
+| Updated `discussion.md` | Round 1 with exploration results |
+
+### Phase 3: Interactive Discussion
+
+| Artifact | Description |
+|----------|-------------|
+| Updated `discussion.md` | Round 2-N with user feedback and insights |
+| Corrected assumptions | Tracked in discussion timeline |
+
+### Phase 4: Synthesis & Conclusion
+
+| Artifact | Description |
+|----------|-------------|
+| `conclusions.json` | Final synthesis with recommendations |
+| Final `discussion.md` | โญ Complete analysis with conclusions |
## Overview
-Interactive collaborative analysis workflow with **documented discussion process**. Records understanding evolution, facilitates multi-round Q&A, and uses CLI tools (Gemini/Codex) for deep exploration.
+Interactive collaborative analysis workflow with **documented discussion process**. Records understanding evolution, facilitates multi-round Q&A, and uses CLI tools for deep exploration.
**Core workflow**: Topic โ Explore โ Discuss โ Document โ Refine โ Conclude
-**Key features**:
-- **discussion.md**: Timeline of discussions and understanding evolution
-- **Multi-round Q&A**: Iterative clarification with user
-- **CLI-assisted exploration**: Gemini/Codex for codebase and concept analysis
-- **Consolidated insights**: Synthesizes discussions into actionable conclusions
-- **Flexible continuation**: Resume analysis sessions to build on previous work
-
-## Usage
-
-```bash
-/workflow:analyze-with-file [FLAGS]
-
-# Flags
--y, --yes Skip confirmations, use recommended settings
--c, --continue Continue existing session (auto-detected if exists)
-
-# Arguments
- Analysis topic, question, or concept to explore (required)
-
-# Examples
-/workflow:analyze-with-file "ๅฆไฝไผๅ่ฟไธช้กน็ฎ็่ฎค่ฏๆถๆ"
-/workflow:analyze-with-file --continue "่ฎค่ฏๆถๆ" # Continue existing session
-/workflow:analyze-with-file -y "ๆง่ฝ็ถ้ขๅๆ" # Auto mode
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ INTERACTIVE ANALYSIS WORKFLOW โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ โ
+โ Phase 1: Topic Understanding โ
+โ โโ Parse topic/question โ
+โ โโ Identify analysis dimensions (architecture, performance, etc.) โ
+โ โโ Initial scoping with user โ
+โ โโ Initialize discussion.md โ
+โ โ
+โ Phase 2: CLI Exploration โ
+โ โโ cli-explore-agent: Codebase context (FIRST) โ
+โ โโ Gemini/Codex: Deep analysis (AFTER exploration) โ
+โ โโ Aggregate findings โ
+โ โโ Update discussion.md with Round 1 โ
+โ โ
+โ Phase 3: Interactive Discussion (Multi-Round) โ
+โ โโ Present exploration findings โ
+โ โโ Facilitate Q&A with user โ
+โ โโ Capture user insights and corrections โ
+โ โโ Actions: Deepen | Adjust direction | Answer questions โ
+โ โโ Update discussion.md with each round โ
+โ โโ Repeat until clarity achieved (max 5 rounds) โ
+โ โ
+โ Phase 4: Synthesis & Conclusion โ
+โ โโ Consolidate all insights โ
+โ โโ Generate conclusions with recommendations โ
+โ โโ Update discussion.md with final synthesis โ
+โ โโ Offer follow-up options (issue/task/report) โ
+โ โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
```
-## Execution Process
+## Output Structure
```
-Session Detection:
- โโ Check if analysis session exists for topic
- โโ EXISTS + discussion.md exists โ Continue mode
- โโ NOT_FOUND โ New session mode
-
-Phase 1: Topic Understanding
- โโ Parse topic/question
- โโ Identify analysis dimensions (architecture, implementation, concept, etc.)
- โโ Initial scoping with user (AskUserQuestion)
- โโ Document initial understanding in discussion.md
-
-Phase 2: CLI Exploration (Parallel)
- โโ Launch cli-explore-agent for codebase context
- โโ Use Gemini/Codex for deep analysis
- โโ Aggregate findings into exploration summary
-
-Phase 3: Interactive Discussion (Multi-Round)
- โโ Present exploration findings
- โโ Facilitate Q&A with user (AskUserQuestion)
- โโ Capture user insights and requirements
- โโ Update discussion.md with each round
- โโ Repeat until user is satisfied or clarity achieved
-
-Phase 4: Synthesis & Conclusion
- โโ Consolidate all insights
- โโ Update discussion.md with conclusions
- โโ Generate actionable recommendations
- โโ Optional: Create follow-up tasks or issues
-
-Output:
- โโ .workflow/.analysis/{slug}-{date}/discussion.md (evolving document)
- โโ .workflow/.analysis/{slug}-{date}/explorations.json (CLI findings)
- โโ .workflow/.analysis/{slug}-{date}/conclusions.json (final synthesis)
+.workflow/.analysis/ANL-{slug}-{date}/
+โโโ discussion.md # โญ Evolution of understanding & discussions
+โโโ exploration-codebase.json # Phase 2: Codebase context
+โโโ explorations.json # Phase 2: Aggregated CLI findings
+โโโ conclusions.json # Phase 4: Final synthesis
```
## Implementation
-### Session Setup & Mode Detection
+### Session Initialization
-```javascript
-const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
+**Objective**: Create session context and directory structure for analysis.
-const topicSlug = topic_or_question.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').substring(0, 40)
-const dateStr = getUtc8ISOString().substring(0, 10)
+**Required Actions**:
+1. Extract topic/question from `$ARGUMENTS`
+2. Generate session ID: `ANL-{slug}-{date}`
+ - slug: lowercase, alphanumeric + Chinese, max 40 chars
+ - date: YYYY-MM-DD (UTC+8)
+3. Define session folder: `.workflow/.analysis/{session-id}`
+4. Parse command options:
+ - `-c` or `--continue` for session continuation
+ - `-y` or `--yes` for auto-approval mode
+5. Auto-detect mode: If session folder + discussion.md exist โ continue mode
+6. Create directory structure: `{session-folder}/`
-const sessionId = `ANL-${topicSlug}-${dateStr}`
-const sessionFolder = `.workflow/.analysis/${sessionId}`
-const discussionPath = `${sessionFolder}/discussion.md`
-const explorationsPath = `${sessionFolder}/explorations.json`
-const conclusionsPath = `${sessionFolder}/conclusions.json`
-
-// Auto-detect mode
-const sessionExists = fs.existsSync(sessionFolder)
-const hasDiscussion = sessionExists && fs.existsSync(discussionPath)
-const forcesContinue = $ARGUMENTS.includes('--continue') || $ARGUMENTS.includes('-c')
-
-const mode = (hasDiscussion || forcesContinue) ? 'continue' : 'new'
-
-if (!sessionExists) {
- bash(`mkdir -p ${sessionFolder}`)
-}
-```
-
----
+**Session Variables**:
+- `sessionId`: Unique session identifier
+- `sessionFolder`: Base directory for all artifacts
+- `autoMode`: Boolean for auto-confirmation
+- `mode`: new | continue
### Phase 1: Topic Understanding
-**Step 1.1: Parse Topic & Identify Dimensions**
+**Objective**: Analyze topic, identify dimensions, gather user input, initialize discussion.md.
-```javascript
-// Analyze topic to determine analysis dimensions
-const ANALYSIS_DIMENSIONS = {
- architecture: ['ๆถๆ', 'architecture', 'design', 'structure', '่ฎพ่ฎก'],
- implementation: ['ๅฎ็ฐ', 'implement', 'code', 'coding', 'ไปฃ็ '],
- performance: ['ๆง่ฝ', 'performance', 'optimize', 'bottleneck', 'ไผๅ'],
- security: ['ๅฎๅ
จ', 'security', 'auth', 'permission', 'ๆ้'],
- concept: ['ๆฆๅฟต', 'concept', 'theory', 'principle', 'ๅ็'],
- comparison: ['ๆฏ่พ', 'compare', 'vs', 'difference', 'ๅบๅซ'],
- decision: ['ๅณ็ญ', 'decision', 'choice', 'tradeoff', '้ๆฉ']
-}
+**Prerequisites**:
+- Session initialized with valid sessionId and sessionFolder
+- Topic/question available from $ARGUMENTS
-function identifyDimensions(topic) {
- const text = topic.toLowerCase()
- const matched = []
+**Workflow Steps**:
- for (const [dimension, keywords] of Object.entries(ANALYSIS_DIMENSIONS)) {
- if (keywords.some(k => text.includes(k))) {
- matched.push(dimension)
- }
- }
+1. **Parse Topic & Identify Dimensions**
+ - Match topic keywords against ANALYSIS_DIMENSIONS
+ - Identify relevant dimensions: architecture, implementation, performance, security, concept, comparison, decision
+ - Default to "general" if no match
- return matched.length > 0 ? matched : ['general']
-}
+2. **Initial Scoping** (if new session + not auto mode)
+ - **Focus**: Multi-select from code implementation, architecture design, best practices, problem diagnosis
+ - **Depth**: Single-select from Quick Overview (10-15min) / Standard Analysis (30-60min) / Deep Dive (1-2hr)
-const dimensions = identifyDimensions(topic_or_question)
-```
+3. **Initialize discussion.md**
+ - Create discussion.md with session metadata
+ - Add user context: focus areas, analysis depth
+ - Add initial understanding: dimensions, scope, key questions
+ - Create empty sections for discussion timeline
-**Step 1.2: Initial Scoping (New Session Only)**
-
-```javascript
-const autoYes = $ARGUMENTS.includes('--yes') || $ARGUMENTS.includes('-y')
-
-if (mode === 'new' && !autoYes) {
- // Ask user to scope the analysis
- AskUserQuestion({
- questions: [
- {
- question: `ๅๆ่ๅด: "${topic_or_question}"\n\nๆจๆณ้็นๅ
ณๆณจๅชไบๆน้ข?`,
- header: "Focus",
- multiSelect: true,
- options: [
- { label: "ไปฃ็ ๅฎ็ฐ", description: "ๅๆ็ฐๆไปฃ็ ๅฎ็ฐ" },
- { label: "ๆถๆ่ฎพ่ฎก", description: "ๆถๆๅฑ้ข็ๅๆ" },
- { label: "ๆไฝณๅฎ่ทต", description: "่กไธๆไฝณๅฎ่ทตๅฏนๆฏ" },
- { label: "้ฎ้ข่ฏๆญ", description: "่ฏๅซๆฝๅจ้ฎ้ข" }
- ]
- },
- {
- question: "ๅๆๆทฑๅบฆ?",
- header: "Depth",
- multiSelect: false,
- options: [
- { label: "Quick Overview", description: "ๅฟซ้ๆฆ่ง (10-15ๅ้)" },
- { label: "Standard Analysis", description: "ๆ ๅๅๆ (30-60ๅ้)" },
- { label: "Deep Dive", description: "ๆทฑๅบฆๅๆ (1-2ๅฐๆถ)" }
- ]
- }
- ]
- })
-}
-```
-
-**Step 1.3: Create/Update discussion.md**
-
-For new session:
-```markdown
-# Analysis Discussion
-
-**Session ID**: ${sessionId}
-**Topic**: ${topic_or_question}
-**Started**: ${getUtc8ISOString()}
-**Dimensions**: ${dimensions.join(', ')}
-
----
-
-## User Context
-
-**Focus Areas**: ${userFocusAreas.join(', ')}
-**Analysis Depth**: ${analysisDepth}
-
----
-
-## Discussion Timeline
-
-### Round 1 - Initial Understanding (${timestamp})
-
-#### Topic Analysis
-
-Based on the topic "${topic_or_question}":
-
-- **Primary dimensions**: ${dimensions.join(', ')}
-- **Initial scope**: ${initialScope}
-- **Key questions to explore**:
- - ${question1}
- - ${question2}
- - ${question3}
-
-#### Next Steps
-
-- Launch CLI exploration for codebase context
-- Gather external insights via Gemini
-- Prepare discussion points for user
-
----
-
-## Current Understanding
-
-${initialUnderstanding}
-```
-
-For continue session, append:
-```markdown
-### Round ${n} - Continuation (${timestamp})
-
-#### Previous Context
-
-Resuming analysis based on prior discussion.
-
-#### New Focus
-
-${newFocusFromUser}
-```
-
----
+**Success Criteria**:
+- Session folder created with discussion.md initialized
+- Analysis dimensions identified
+- User preferences captured (focus, depth)
### Phase 2: CLI Exploration
-**โ ๏ธ CRITICAL - EXPLORATION PRIORITY**:
-- **cli-explore-agent FIRST**: Always use cli-explore-agent as primary exploration method
-- **CLI calls AFTER**: Use Gemini/other CLI tools only after cli-explore-agent provides context
-- **Sequential execution**: cli-explore-agent โ CLI deep analysis (not parallel)
-- Minimize output: No processing until 100% results available
+**Objective**: Gather codebase context, then execute deep analysis via CLI tools.
-**Step 2.1: Primary Exploration via cli-explore-agent**
+**Prerequisites**:
+- Phase 1 completed successfully
+- discussion.md initialized
+- Dimensions identified
+**Workflow Steps**:
+
+1. **Primary Codebase Exploration via cli-explore-agent** (โ ๏ธ FIRST)
+ - Agent type: `cli-explore-agent`
+ - Execution mode: synchronous (run_in_background: false)
+ - **Tasks**:
+ - Run: `ccw tool exec get_modules_by_depth '{}'`
+ - Execute searches based on topic keywords
+ - Read: `.workflow/project-tech.json` if exists
+ - **Output**: `{sessionFolder}/exploration-codebase.json`
+ - relevant_files: [{path, relevance, rationale}]
+ - patterns: []
+ - key_findings: []
+ - questions_for_user: []
+ - **Purpose**: Enrich CLI prompts with codebase context
+
+**Agent Call Example**:
```javascript
-// โ ๏ธ PRIORITY: cli-explore-agent is the PRIMARY exploration method
-// MUST complete before any CLI calls
-
-const codebaseExploration = await Task(
- subagent_type="cli-explore-agent",
- run_in_background=false,
- description=`Explore codebase: ${topicSlug}`,
- prompt=`
+Task({
+ subagent_type: "cli-explore-agent",
+ run_in_background: false,
+ description: `Explore codebase: ${topicSlug}`,
+ prompt: `
## Analysis Context
Topic: ${topic_or_question}
Dimensions: ${dimensions.join(', ')}
@@ -277,28 +211,28 @@ Write findings to: ${sessionFolder}/exploration-codebase.json
Schema:
{
- "relevant_files": [{path, relevance, rationale}],
+ "relevant_files": [{"path": "...", "relevance": "high|medium|low", "rationale": "..."}],
"patterns": [],
"key_findings": [],
"questions_for_user": [],
"_metadata": { "exploration_type": "codebase", "timestamp": "..." }
}
`
-)
-
-// Read exploration results for CLI context enrichment
-const explorationResults = Read(`${sessionFolder}/exploration-codebase.json`)
+})
```
-**Step 2.2: CLI Deep Analysis (Using Exploration Context)**
+2. **CLI Deep Analysis** (โ ๏ธ AFTER exploration)
+ - Launch Gemini CLI with analysis mode
+ - **Shared context**: Include exploration-codebase.json findings in prompt
+ - **Execution**: Bash with run_in_background: true, wait for results
+ - **Output**: Analysis findings with insights and discussion points
+**CLI Call Example**:
```javascript
-// Gemini CLI for deep analysis - AFTER cli-explore-agent completes
-// โ ๏ธ CRITICAL: Must wait for CLI completion before aggregating
Bash({
command: `ccw cli -p "
PURPOSE: Analyze topic '${topic_or_question}' from ${dimensions.join(', ')} perspectives
-Success criteria: Actionable insights with clear reasoning
+Success: Actionable insights with clear reasoning
PRIOR EXPLORATION CONTEXT:
- Key files: ${explorationResults.relevant_files.slice(0,5).map(f => f.path).join(', ')}
@@ -325,453 +259,194 @@ CONSTRAINTS: Focus on ${dimensions.join(', ')}
" --tool gemini --mode analysis`,
run_in_background: true
})
+
+// โ ๏ธ STOP POINT: Wait for hook callback to receive results before continuing
```
-**โ ๏ธ STOP POINT**: After launching CLI call, stop output immediately. Wait for hook callback to receive results before continuing to Step 2.3.
+3. **Aggregate Findings**
+ - Consolidate codebase and CLI findings
+ - Extract key findings, discussion points, open questions
+ - Write to explorations.json
-**Step 2.3: Aggregate Findings**
+4. **Update discussion.md**
+ - Append Round 1 section with exploration results
+ - Include sources analyzed, key findings, discussion points, open questions
-```javascript
-// After explorations complete, aggregate into explorations.json
-const explorations = {
- session_id: sessionId,
- timestamp: getUtc8ISOString(),
- topic: topic_or_question,
- dimensions: dimensions,
- sources: [
- { type: "codebase", file: "exploration-codebase.json" },
- { type: "gemini", summary: geminiOutput }
- ],
- key_findings: [...],
- discussion_points: [...],
- open_questions: [...]
-}
+**explorations.json Schema**:
+- `session_id`: Session identifier
+- `timestamp`: Exploration completion time
+- `topic`: Original topic/question
+- `dimensions[]`: Analysis dimensions
+- `sources[]`: {type, file/summary}
+- `key_findings[]`: Main insights
+- `discussion_points[]`: Questions for user
+- `open_questions[]`: Unresolved questions
-Write(explorationsPath, JSON.stringify(explorations, null, 2))
-```
+**Success Criteria**:
+- exploration-codebase.json created with codebase context
+- explorations.json created with aggregated findings
+- discussion.md updated with Round 1 results
+- All CLI calls completed successfully
-**Step 2.4: Update discussion.md**
+### Phase 3: Interactive Discussion
-```markdown
-#### Exploration Results (${timestamp})
+**Objective**: Iteratively refine understanding through user-guided discussion cycles.
-**Sources Analyzed**:
-${sources.map(s => `- ${s.type}: ${s.summary}`).join('\n')}
+**Prerequisites**:
+- Phase 2 completed successfully
+- explorations.json contains initial findings
+- discussion.md has Round 1 results
-**Key Findings**:
-${keyFindings.map((f, i) => `${i+1}. ${f}`).join('\n')}
+**Workflow Steps**:
-**Points for Discussion**:
-${discussionPoints.map((p, i) => `${i+1}. ${p}`).join('\n')}
+1. **Present Findings**
+ - Display current findings from explorations.json
+ - Show key points for user input
-**Open Questions**:
-${openQuestions.map((q, i) => `- ${q}`).join('\n')}
-```
+2. **Gather User Feedback** (AskUserQuestion)
+ - **Question**: Feedback on current analysis
+ - **Options** (single-select):
+ - **ๅๆ๏ผ็ปง็ปญๆทฑๅ
ฅ**: Analysis direction correct, deepen exploration
+ - **้่ฆ่ฐๆดๆนๅ**: Different understanding or focus
+ - **ๅๆๅฎๆ**: Sufficient information obtained
+ - **ๆๅ
ทไฝ้ฎ้ข**: Specific questions to ask
----
+3. **Process User Response**
-### Phase 3: Interactive Discussion (Multi-Round)
+ **Agree, Deepen**:
+ - Continue analysis in current direction
+ - Use CLI for deeper exploration
-**Step 3.1: Present Findings & Gather Feedback**
+ **Adjust Direction**:
+ - AskUserQuestion for adjusted focus (code details / architecture / best practices)
+ - Launch new CLI exploration with adjusted scope
-```javascript
-// Maximum discussion rounds
-const MAX_ROUNDS = 5
-let roundNumber = 1
-let discussionComplete = false
+ **Specific Questions**:
+ - Capture user questions
+ - Use CLI or direct analysis to answer
+ - Document Q&A in discussion.md
-while (!discussionComplete && roundNumber <= MAX_ROUNDS) {
- // Display current findings
- console.log(`
-## Discussion Round ${roundNumber}
+ **Complete**:
+ - Exit discussion loop, proceed to Phase 4
-${currentFindings}
+4. **Update discussion.md**
+ - Append Round N section with:
+ - User input summary
+ - Direction adjustment (if any)
+ - User questions & answers (if any)
+ - Updated understanding
+ - Corrected assumptions
+ - New insights
-### Key Points for Your Input
-${discussionPoints.map((p, i) => `${i+1}. ${p}`).join('\n')}
-`)
+5. **Repeat or Converge**
+ - Continue loop (max 5 rounds) or exit to Phase 4
- // Gather user input
- const userResponse = AskUserQuestion({
- questions: [
- {
- question: "ๅฏนไปฅไธๅๆๆไปไน็ๆณๆ่กฅๅ
?",
- header: "Feedback",
- multiSelect: false,
- options: [
- { label: "ๅๆ๏ผ็ปง็ปญๆทฑๅ
ฅ", description: "ๅๆๆนๅๆญฃ็กฎ๏ผ็ปง็ปญๆข็ดข" },
- { label: "้่ฆ่ฐๆดๆนๅ", description: "ๆๆไธๅ็็่งฃๆ้็น" },
- { label: "ๅๆๅฎๆ", description: "ๅทฒ่ทๅพ่ถณๅคไฟกๆฏ" },
- { label: "ๆๅ
ทไฝ้ฎ้ข", description: "ๆๆณ้ฎไธไบๅ
ทไฝ้ฎ้ข" }
- ]
- }
- ]
- })
+**Discussion Actions**:
- // Process user response
- switch (userResponse.feedback) {
- case "ๅๆ๏ผ็ปง็ปญๆทฑๅ
ฅ":
- // Deepen analysis in current direction
- await deepenAnalysis()
- break
- case "้่ฆ่ฐๆดๆนๅ":
- // Get user's adjusted focus
- const adjustment = AskUserQuestion({
- questions: [{
- question: "่ฏท่ฏดๆๆจๅธๆ่ฐๆด็ๆนๅๆ้็น:",
- header: "Direction",
- multiSelect: false,
- options: [
- { label: "ๆดๅคไปฃ็ ็ป่", description: "ๆทฑๅ
ฅไปฃ็ ๅฎ็ฐ" },
- { label: "ๆดๅคๆถๆ่ง่ง", description: "ๅ
ณๆณจๆดไฝ่ฎพ่ฎก" },
- { label: "ๆดๅคๅฎ่ทตๅฏนๆฏ", description: "ๅฏนๆฏๆไฝณๅฎ่ทต" }
- ]
- }]
- })
- await adjustAnalysisDirection(adjustment)
- break
- case "ๅๆๅฎๆ":
- discussionComplete = true
- break
- case "ๆๅ
ทไฝ้ฎ้ข":
- // Let user ask specific questions, then answer
- await handleUserQuestions()
- break
- }
+| User Choice | Action | Tool | Description |
+|-------------|--------|------|-------------|
+| Deepen | Continue current direction | Gemini CLI | Deeper analysis in same focus |
+| Adjust | Change analysis angle | Selected CLI | New exploration with adjusted scope |
+| Questions | Answer specific questions | CLI or analysis | Address user inquiries |
+| Complete | Exit discussion loop | - | Proceed to synthesis |
- // Update discussion.md with this round
- updateDiscussionDocument(roundNumber, userResponse, findings)
- roundNumber++
-}
-```
-
-**Step 3.2: Document Each Round**
-
-Append to discussion.md:
-```markdown
-### Round ${n} - Discussion (${timestamp})
-
-#### User Input
-
-${userInputSummary}
-
-${userResponse === 'adjustment' ? `
-**Direction Adjustment**: ${adjustmentDetails}
-` : ''}
-
-${userResponse === 'questions' ? `
-**User Questions**:
-${userQuestions.map((q, i) => `${i+1}. ${q}`).join('\n')}
-
-**Answers**:
-${answers.map((a, i) => `${i+1}. ${a}`).join('\n')}
-` : ''}
-
-#### Updated Understanding
-
-Based on user feedback:
-- ${insight1}
-- ${insight2}
-
-#### Corrected Assumptions
-
-${corrections.length > 0 ? corrections.map(c => `
-- ~~${c.wrong}~~ โ ${c.corrected}
- - Reason: ${c.reason}
-`).join('\n') : 'None'}
-
-#### New Insights
-
-${newInsights.map(i => `- ${i}`).join('\n')}
-```
-
----
+**Success Criteria**:
+- User feedback processed for each round
+- discussion.md updated with all discussion rounds
+- Assumptions corrected and documented
+- Exit condition reached (user selects "ๅฎๆ" or max rounds)
### Phase 4: Synthesis & Conclusion
-**Step 4.1: Consolidate Insights**
-
-```javascript
-const conclusions = {
- session_id: sessionId,
- topic: topic_or_question,
- completed: getUtc8ISOString(),
- total_rounds: roundNumber,
-
- summary: "...",
-
- key_conclusions: [
- { point: "...", evidence: "...", confidence: "high|medium|low" }
- ],
-
- recommendations: [
- { action: "...", rationale: "...", priority: "high|medium|low" }
- ],
-
- open_questions: [...],
-
- follow_up_suggestions: [
- { type: "issue", summary: "..." },
- { type: "task", summary: "..." }
- ]
-}
-
-Write(conclusionsPath, JSON.stringify(conclusions, null, 2))
-```
-
-**Step 4.2: Final discussion.md Update**
-
-```markdown
----
-
-## Conclusions (${timestamp})
-
-### Summary
-
-${summaryParagraph}
-
-### Key Conclusions
-
-${conclusions.key_conclusions.map((c, i) => `
-${i+1}. **${c.point}** (Confidence: ${c.confidence})
- - Evidence: ${c.evidence}
-`).join('\n')}
-
-### Recommendations
-
-${conclusions.recommendations.map((r, i) => `
-${i+1}. **${r.action}** (Priority: ${r.priority})
- - Rationale: ${r.rationale}
-`).join('\n')}
-
-### Remaining Questions
-
-${conclusions.open_questions.map(q => `- ${q}`).join('\n')}
-
----
-
-## Current Understanding (Final)
-
-### What We Established
-
-${establishedPoints.map(p => `- ${p}`).join('\n')}
-
-### What Was Clarified/Corrected
-
-${corrections.map(c => `- ~~${c.original}~~ โ ${c.corrected}`).join('\n')}
-
-### Key Insights
-
-${keyInsights.map(i => `- ${i}`).join('\n')}
-
----
-
-## Session Statistics
-
-- **Total Rounds**: ${totalRounds}
-- **Duration**: ${duration}
-- **Sources Used**: ${sources.join(', ')}
-- **Artifacts Generated**: discussion.md, explorations.json, conclusions.json
-```
-
-**Step 4.3: Post-Completion Options**
-
-```javascript
-AskUserQuestion({
- questions: [{
- question: "ๅๆๅฎๆใๆฏๅฆ้่ฆๅ็ปญๆไฝ?",
- header: "Next Steps",
- multiSelect: true,
- options: [
- { label: "ๅๅปบIssue", description: "ๅฐ็ป่ฎบ่ฝฌไธบๅฏๆง่ก็Issue" },
- { label: "็ๆไปปๅก", description: "ๅๅปบๅฎๆฝไปปๅก" },
- { label: "ๅฏผๅบๆฅๅ", description: "็ๆ็ฌ็ซ็ๅๆๆฅๅ" },
- { label: "ๅฎๆ", description: "ไธ้่ฆๅ็ปญๆไฝ" }
- ]
- }]
-})
-
-// Handle selections
-if (selection.includes("ๅๅปบIssue")) {
- Skill(skill="issue:new", args=`${topic_or_question} - ๅๆ็ป่ฎบๅฎๆฝ`)
-}
-if (selection.includes("็ๆไปปๅก")) {
- Skill(skill="workflow:lite-plan", args=`ๅฎๆฝๅๆ็ป่ฎบ: ${summary}`)
-}
-if (selection.includes("ๅฏผๅบๆฅๅ")) {
- exportAnalysisReport(sessionFolder)
-}
-```
-
----
-
-## Session Folder Structure
-
-```
-.workflow/.analysis/ANL-{slug}-{date}/
-โโโ discussion.md # Evolution of understanding & discussions
-โโโ explorations.json # CLI exploration findings
-โโโ conclusions.json # Final synthesis
-โโโ exploration-*.json # Individual exploration results (optional)
-```
-
-## Discussion Document Template
-
-```markdown
-# Analysis Discussion
-
-**Session ID**: ANL-xxx-2025-01-25
-**Topic**: [topic or question]
-**Started**: 2025-01-25T10:00:00+08:00
-**Dimensions**: [architecture, implementation, ...]
-
----
-
-## User Context
-
-**Focus Areas**: [user-selected focus]
-**Analysis Depth**: [quick|standard|deep]
-
----
-
-## Discussion Timeline
-
-### Round 1 - Initial Understanding (2025-01-25 10:00)
-
-#### Topic Analysis
-...
-
-#### Exploration Results
-...
-
-### Round 2 - Discussion (2025-01-25 10:15)
-
-#### User Input
-...
-
-#### Updated Understanding
-...
-
-#### Corrected Assumptions
-- ~~[wrong]~~ โ [corrected]
-
-### Round 3 - Deep Dive (2025-01-25 10:30)
-...
-
----
-
-## Conclusions (2025-01-25 11:00)
-
-### Summary
-...
-
-### Key Conclusions
-...
-
-### Recommendations
-...
-
----
-
-## Current Understanding (Final)
-
-### What We Established
-- [confirmed points]
-
-### What Was Clarified/Corrected
-- ~~[original assumption]~~ โ [corrected understanding]
-
-### Key Insights
-- [insights gained]
-
----
-
-## Session Statistics
-
-- **Total Rounds**: 3
-- **Duration**: 1 hour
-- **Sources Used**: codebase exploration, Gemini analysis
-- **Artifacts Generated**: discussion.md, explorations.json, conclusions.json
-```
-
-## Iteration Flow
-
-```
-First Call (/workflow:analyze-with-file "topic"):
- โโ No session exists โ New mode
- โโ Identify analysis dimensions
- โโ Scope with user (unless --yes)
- โโ Create discussion.md with initial understanding
- โโ Launch CLI explorations
- โโ Enter discussion loop
-
-Continue Call (/workflow:analyze-with-file --continue "topic"):
- โโ Session exists โ Continue mode
- โโ Load discussion.md
- โโ Resume from last round
- โโ Continue discussion loop
-
-Discussion Loop:
- โโ Present current findings
- โโ Gather user feedback (AskUserQuestion)
- โโ Process response:
- โ โโ Agree โ Deepen analysis
- โ โโ Adjust โ Change direction
- โ โโ Question โ Answer then continue
- โ โโ Complete โ Exit loop
- โโ Update discussion.md
- โโ Repeat until complete or max rounds
-
-Completion:
- โโ Generate conclusions.json
- โโ Update discussion.md with final synthesis
- โโ Offer follow-up options (issue, task, report)
-```
-
-## CLI Integration Points
-
-### 1. Codebase Exploration (cli-explore-agent)
-
-**Purpose**: Gather relevant code context
-
-**When**: Topic involves implementation or architecture analysis
-
-### 2. Gemini Deep Analysis
-
-**Purpose**: Conceptual analysis, pattern identification, best practices
-
-**Prompt Pattern**:
-```
-PURPOSE: Analyze topic + identify insights
-TASK: Explore dimensions + generate discussion points
-CONTEXT: Codebase + topic
-EXPECTED: Structured analysis + questions
-```
-
-### 3. Follow-up CLI Calls
-
-**Purpose**: Deepen specific areas based on user feedback
-
-**Dynamic invocation** based on discussion direction
-
-## Consolidation Rules
+**Objective**: Consolidate insights, generate conclusions, offer next steps.
+
+**Prerequisites**:
+- Phase 3 completed successfully
+- Multiple rounds of discussion documented
+- User ready to conclude
+
+**Workflow Steps**:
+
+1. **Consolidate Insights**
+ - Extract all findings from discussion timeline
+ - **Key conclusions**: Main points with evidence and confidence levels (high/medium/low)
+ - **Recommendations**: Action items with rationale and priority (high/medium/low)
+ - **Open questions**: Remaining unresolved questions
+ - **Follow-up suggestions**: Issue/task creation suggestions
+ - Write to conclusions.json
+
+2. **Final discussion.md Update**
+ - Append conclusions section:
+ - **Summary**: High-level overview
+ - **Key Conclusions**: Ranked with evidence and confidence
+ - **Recommendations**: Prioritized action items
+ - **Remaining Questions**: Unresolved items
+ - Update "Current Understanding (Final)":
+ - **What We Established**: Confirmed points
+ - **What Was Clarified/Corrected**: Important corrections
+ - **Key Insights**: Valuable learnings
+ - Add session statistics: rounds, duration, sources, artifacts
+
+3. **Post-Completion Options** (AskUserQuestion)
+ - **ๅๅปบIssue**: Launch issue:new with conclusions
+ - **็ๆไปปๅก**: Launch workflow:lite-plan for implementation
+ - **ๅฏผๅบๆฅๅ**: Generate standalone analysis report
+ - **ๅฎๆ**: No further action
+
+**conclusions.json Schema**:
+- `session_id`: Session identifier
+- `topic`: Original topic/question
+- `completed`: Completion timestamp
+- `total_rounds`: Number of discussion rounds
+- `summary`: Executive summary
+- `key_conclusions[]`: {point, evidence, confidence}
+- `recommendations[]`: {action, rationale, priority}
+- `open_questions[]`: Unresolved questions
+- `follow_up_suggestions[]`: {type, summary}
+
+**Success Criteria**:
+- conclusions.json created with final synthesis
+- discussion.md finalized with conclusions
+- User offered next step options
+- Session complete
+
+## Configuration
+
+### Analysis Dimensions
+
+Dimensions matched against topic keywords to identify focus areas:
+
+| Dimension | Keywords |
+|-----------|----------|
+| architecture | ๆถๆ, architecture, design, structure, ่ฎพ่ฎก |
+| implementation | ๅฎ็ฐ, implement, code, coding, ไปฃ็ |
+| performance | ๆง่ฝ, performance, optimize, bottleneck, ไผๅ |
+| security | ๅฎๅ
จ, security, auth, permission, ๆ้ |
+| concept | ๆฆๅฟต, concept, theory, principle, ๅ็ |
+| comparison | ๆฏ่พ, compare, vs, difference, ๅบๅซ |
+| decision | ๅณ็ญ, decision, choice, tradeoff, ้ๆฉ |
+
+### Consolidation Rules
When updating "Current Understanding":
-1. **Promote confirmed insights**: Move validated findings to "What We Established"
-2. **Track corrections**: Keep important wrongโright transformations
-3. **Focus on current state**: What do we know NOW
-4. **Avoid timeline repetition**: Don't copy discussion details
-5. **Preserve key learnings**: Keep insights valuable for future reference
+| Rule | Description |
+|------|-------------|
+| Promote confirmed insights | Move validated findings to "What We Established" |
+| Track corrections | Keep important wrongโright transformations |
+| Focus on current state | What do we know NOW |
+| Avoid timeline repetition | Don't copy discussion details |
+| Preserve key learnings | Keep insights valuable for future reference |
-**Bad (cluttered)**:
+**Example**:
+
+โ **Bad (cluttered)**:
```markdown
## Current Understanding
-
-In round 1 we discussed X, then in round 2 user said Y, and we explored Z...
+In round 1 we discussed X, then in round 2 user said Y...
```
-**Good (consolidated)**:
+โ
**Good (consolidated)**:
```markdown
## Current Understanding
@@ -784,36 +459,86 @@ In round 1 we discussed X, then in round 2 user said Y, and we explored Z...
### Key Insights
- Current architecture supports horizontal scaling
-- Security audit recommended before production
```
## Error Handling
-| Situation | Action |
-|-----------|--------|
-| CLI exploration fails | Continue with available context, note limitation |
+| Error | Resolution |
+|-------|------------|
+| cli-explore-agent fails | Continue with available context, note limitation |
+| CLI timeout | Retry with shorter prompt, or skip perspective |
| User timeout in discussion | Save state, show resume command |
| Max rounds reached | Force synthesis, offer continuation option |
| No relevant findings | Broaden search, ask user for clarification |
| Session folder conflict | Append timestamp suffix |
| Gemini unavailable | Fallback to Codex or manual analysis |
+## Best Practices
+
+1. **Clear Topic Definition**: Detailed topics โ better dimension identification
+2. **Review discussion.md**: Check understanding evolution before conclusions
+3. **Embrace Corrections**: Track wrongโright transformations as learnings
+4. **Document Evolution**: discussion.md captures full thinking process
+5. **Use Continue Mode**: Resume sessions to build on previous analysis
+
+## Templates
+
+### Discussion Document Structure
+
+**discussion.md** contains:
+- **Header**: Session metadata (ID, topic, started, dimensions)
+- **User Context**: Focus areas, analysis depth
+- **Discussion Timeline**: Round-by-round findings
+ - Round 1: Initial Understanding + Exploration Results
+ - Round 2-N: User feedback, adjusted understanding, corrections, new insights
+- **Conclusions**: Summary, key conclusions, recommendations
+- **Current Understanding (Final)**: Consolidated insights
+- **Session Statistics**: Rounds, duration, sources, artifacts
+
+Example sections:
+
+```markdown
+### Round 2 - Discussion (timestamp)
+
+#### User Input
+User agrees with current direction, wants deeper code analysis
+
+#### Updated Understanding
+- Identified session management uses database-backed approach
+- Rate limiting applied at gateway, not application level
+
+#### Corrected Assumptions
+- ~~Assumed Redis for sessions~~ โ Database-backed sessions
+ - Reason: User clarified architecture decision
+
+#### New Insights
+- Current design allows horizontal scaling without session affinity
+```
## Usage Recommendations
-Use `/workflow:analyze-with-file` when:
+**Use `/workflow:analyze-with-file` when:**
- Exploring a complex topic collaboratively
- Need documented discussion trail
- Decision-making requires multiple perspectives
- Want to iterate on understanding with user input
- Building shared understanding before implementation
-Use `/workflow:debug-with-file` when:
+**Use `/workflow:debug-with-file` when:**
- Diagnosing specific bugs
- Need hypothesis-driven investigation
- Focus on evidence and verification
-Use `/workflow:lite-plan` when:
+**Use `/workflow:brainstorm-with-file` when:**
+- Generating new ideas or solutions
+- Need creative exploration
+- Want divergent thinking before convergence
+
+**Use `/workflow:lite-plan` when:**
- Ready to implement (past analysis phase)
- Need structured task breakdown
- Focus on execution planning
+
+---
+
+**Now execute analyze-with-file for**: $ARGUMENTS
diff --git a/.claude/commands/workflow/brainstorm-with-file.md b/.claude/commands/workflow/brainstorm-with-file.md
index ac2fc992..5f193058 100644
--- a/.claude/commands/workflow/brainstorm-with-file.md
+++ b/.claude/commands/workflow/brainstorm-with-file.md
@@ -7,9 +7,56 @@ allowed-tools: TodoWrite(*), Task(*), AskUserQuestion(*), Read(*), Grep(*), Glob
## Auto Mode
-When `--yes` or `-y`: Auto-confirm decisions, use balanced exploration across all perspectives.
+When `--yes` or `-y`: Auto-confirm decisions, use recommended roles, balanced exploration mode.
-# Workflow Brainstorm-With-File Command
+# Workflow Brainstorm Command
+
+## Quick Start
+
+```bash
+# Basic usage
+/workflow:brainstorm-with-file "ๅฆไฝ้ๆฐ่ฎพ่ฎก็จๆท้็ฅ็ณป็ป"
+
+# With options
+/workflow:brainstorm-with-file --continue "้็ฅ็ณป็ป" # Continue existing
+/workflow:brainstorm-with-file -y -m creative "ๅๆฐ็AI่พ
ๅฉๅ่ฝ" # Creative auto mode
+/workflow:brainstorm-with-file -m structured "ไผๅ็ผๅญ็ญ็ฅ" # Goal-oriented mode
+```
+
+**Context Source**: cli-explore-agent + Multi-CLI perspectives (Gemini/Codex/Claude or Professional Roles)
+**Output Directory**: `.workflow/.brainstorm/{session-id}/`
+**Core Innovation**: Diverge-Converge cycles with documented thought evolution
+
+## Output Artifacts
+
+### Phase 1: Seed Understanding
+
+| Artifact | Description |
+|----------|-------------|
+| `brainstorm.md` | Complete thought evolution timeline (initialized) |
+| Session variables | Dimensions, roles, exploration vectors |
+
+### Phase 2: Divergent Exploration
+
+| Artifact | Description |
+|----------|-------------|
+| `exploration-codebase.json` | Codebase context from cli-explore-agent |
+| `perspectives.json` | Multi-CLI perspective findings (creative/pragmatic/systematic) |
+| Updated `brainstorm.md` | Round 2 multi-perspective exploration |
+
+### Phase 3: Interactive Refinement
+
+| Artifact | Description |
+|----------|-------------|
+| `ideas/{idea-slug}.md` | Deep-dive analysis for selected ideas |
+| Updated `brainstorm.md` | Round 3-6 refinement cycles |
+
+### Phase 4: Convergence & Crystallization
+
+| Artifact | Description |
+|----------|-------------|
+| `synthesis.json` | Final synthesis with top ideas, recommendations |
+| Final `brainstorm.md` | โญ Complete thought evolution with conclusions |
## Overview
@@ -17,200 +64,133 @@ Interactive brainstorming workflow with **multi-CLI collaboration** and **docume
**Core workflow**: Seed Idea โ Expand โ Multi-CLI Discuss โ Synthesize โ Refine โ Crystallize
-**Key features**:
-- **brainstorm.md**: Complete thought evolution timeline
-- **Multi-CLI collaboration**: Gemini (creative), Codex (pragmatic), Claude (systematic) perspectives
-- **Idea expansion**: Progressive questioning and exploration
-- **Diverge-Converge cycles**: Generate options then focus on best paths
-- **Synthesis**: Merge multiple perspectives into coherent solutions
-
-## Usage
-
-```bash
-/workflow:brainstorm-with-file [FLAGS]
-
-# Flags
--y, --yes Skip confirmations, use recommended settings
--c, --continue Continue existing session (auto-detected if exists)
--m, --mode Brainstorm mode: creative (divergent) | structured (goal-oriented)
-
-# Arguments
- Initial idea, problem, or topic to brainstorm (required)
-
-# Examples
-/workflow:brainstorm-with-file "ๅฆไฝ้ๆฐ่ฎพ่ฎก็จๆท้็ฅ็ณป็ป"
-/workflow:brainstorm-with-file --continue "้็ฅ็ณป็ป" # Continue existing
-/workflow:brainstorm-with-file -y -m creative "ๅๆฐ็AI่พ
ๅฉๅ่ฝ" # Creative auto mode
-/workflow:brainstorm-with-file -m structured "ไผๅ็ผๅญ็ญ็ฅ" # Goal-oriented mode
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ INTERACTIVE BRAINSTORMING WORKFLOW โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ โ
+โ Phase 1: Seed Understanding โ
+โ โโ Parse initial idea/topic โ
+โ โโ Identify dimensions (technical, UX, business, etc.) โ
+โ โโ Select roles (professional or simple perspectives) โ
+โ โโ Initial scoping questions โ
+โ โโ Expand into exploration vectors โ
+โ โโ Initialize brainstorm.md โ
+โ โ
+โ Phase 2: Divergent Exploration โ
+โ โโ cli-explore-agent: Codebase context (FIRST) โ
+โ โโ Multi-CLI Perspectives (AFTER exploration) โ
+โ โ โโ Creative (Gemini): Innovation, cross-domain โ
+โ โ โโ Pragmatic (Codex): Implementation, feasibility โ
+โ โ โโ Systematic (Claude): Architecture, structure โ
+โ โโ Aggregate diverse viewpoints โ
+โ โ
+โ Phase 3: Interactive Refinement (Multi-Round) โ
+โ โโ Present multi-perspective findings โ
+โ โโ User selects promising directions โ
+โ โโ Actions: Deep dive | Generate more | Challenge | Merge โ
+โ โโ Update brainstorm.md with evolution โ
+โ โโ Repeat diverge-converge cycles (max 6 rounds) โ
+โ โ
+โ Phase 4: Convergence & Crystallization โ
+โ โโ Synthesize best ideas โ
+โ โโ Resolve conflicts between perspectives โ
+โ โโ Generate actionable conclusions โ
+โ โโ Offer next steps (plan/issue/analyze/export) โ
+โ โโ Final brainstorm.md update โ
+โ โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
```
-## Execution Process
+## Output Structure
```
-Session Detection:
- โโ Check if brainstorm session exists for topic
- โโ EXISTS + brainstorm.md exists โ Continue mode
- โโ NOT_FOUND โ New session mode
-
-Phase 1: Seed Understanding
- โโ Parse initial idea/topic
- โโ Identify brainstorm dimensions (technical, UX, business, etc.)
- โโ Initial scoping questions (AskUserQuestion)
- โโ Expand seed into exploration vectors
- โโ Document in brainstorm.md
-
-Phase 2: Divergent Exploration (Multi-CLI Parallel)
- โโ Gemini CLI: Creative/innovative perspectives
- โโ Codex CLI: Pragmatic/implementation perspectives
- โโ Claude CLI: Systematic/architectural perspectives
- โโ Aggregate diverse viewpoints
-
-Phase 3: Interactive Refinement (Multi-Round)
- โโ Present multi-perspective findings
- โโ User selects promising directions
- โโ Deep dive on selected paths
- โโ Challenge assumptions (devil's advocate)
- โโ Update brainstorm.md with evolution
- โโ Repeat diverge-converge cycles
-
-Phase 4: Convergence & Crystallization
- โโ Synthesize best ideas
- โโ Resolve conflicts between perspectives
- โโ Formulate actionable conclusions
- โโ Generate next steps or implementation plan
- โโ Final brainstorm.md update
-
-Output:
- โโ .workflow/.brainstorm/{slug}-{date}/brainstorm.md (thought evolution)
- โโ .workflow/.brainstorm/{slug}-{date}/perspectives.json (CLI findings)
- โโ .workflow/.brainstorm/{slug}-{date}/synthesis.json (final ideas)
- โโ .workflow/.brainstorm/{slug}-{date}/ideas/ (individual idea deep-dives)
+.workflow/.brainstorm/BS-{slug}-{date}/
+โโโ brainstorm.md # โญ Complete thought evolution timeline
+โโโ exploration-codebase.json # Phase 2: Codebase context
+โโโ perspectives.json # Phase 2: Multi-CLI findings
+โโโ synthesis.json # Phase 4: Final synthesis
+โโโ ideas/ # Phase 3: Individual idea deep-dives
+ โโโ idea-1.md
+ โโโ idea-2.md
+ โโโ merged-idea-1.md
```
----
-
## Implementation
-### Session Setup & Mode Detection
+### Session Initialization
-```javascript
-const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
+**Objective**: Create session context and directory structure for brainstorming.
-const topicSlug = idea_or_topic.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').substring(0, 40)
-const dateStr = getUtc8ISOString().substring(0, 10)
+**Required Actions**:
+1. Extract idea/topic from `$ARGUMENTS`
+2. Generate session ID: `BS-{slug}-{date}`
+ - slug: lowercase, alphanumeric + Chinese, max 40 chars
+ - date: YYYY-MM-DD (UTC+8)
+3. Define session folder: `.workflow/.brainstorm/{session-id}`
+4. Parse command options:
+ - `-c` or `--continue` for session continuation
+ - `-m` or `--mode` for brainstorm mode (creative/structured/balanced)
+ - `-y` or `--yes` for auto-approval mode
+5. Auto-detect mode: If session folder + brainstorm.md exist โ continue mode
+6. Create directory structure: `{session-folder}/ideas/`
-const sessionId = `BS-${topicSlug}-${dateStr}`
-const sessionFolder = `.workflow/.brainstorm/${sessionId}`
-const brainstormPath = `${sessionFolder}/brainstorm.md`
-const perspectivesPath = `${sessionFolder}/perspectives.json`
-const synthesisPath = `${sessionFolder}/synthesis.json`
-const ideasFolder = `${sessionFolder}/ideas`
-
-// Auto-detect mode
-const sessionExists = fs.existsSync(sessionFolder)
-const hasBrainstorm = sessionExists && fs.existsSync(brainstormPath)
-const forcesContinue = $ARGUMENTS.includes('--continue') || $ARGUMENTS.includes('-c')
-
-const mode = (hasBrainstorm || forcesContinue) ? 'continue' : 'new'
-
-// Brainstorm mode
-const brainstormMode = $ARGUMENTS.includes('--mode')
- ? $ARGUMENTS.match(/--mode\s+(creative|structured)/)?.[1] || 'balanced'
- : 'balanced'
-
-if (!sessionExists) {
- bash(`mkdir -p ${sessionFolder}/ideas`)
-}
-```
-
----
+**Session Variables**:
+- `sessionId`: Unique session identifier
+- `sessionFolder`: Base directory for all artifacts
+- `brainstormMode`: creative | structured | balanced
+- `autoMode`: Boolean for auto-confirmation
+- `mode`: new | continue
### Phase 1: Seed Understanding
-**Step 1.1: Parse Seed & Identify Dimensions**
+**Objective**: Analyze topic, select roles, gather user input, expand into exploration vectors.
+**Prerequisites**:
+- Session initialized with valid sessionId and sessionFolder
+- Topic/idea available from $ARGUMENTS
+
+**Workflow Steps**:
+
+1. **Parse Seed & Identify Dimensions**
+ - Match topic keywords against BRAINSTORM_DIMENSIONS
+ - Identify relevant dimensions: technical, ux, business, innovation, feasibility, scalability, security
+ - Default dimensions based on brainstormMode if no match
+
+2. **Role Selection**
+ - **Recommend roles** based on topic keywords (see Role Keywords mapping)
+ - **Options**:
+ - **Professional roles**: system-architect, product-manager, ui-designer, ux-expert, data-architect, test-strategist, subject-matter-expert, product-owner, scrum-master
+ - **Simple perspectives**: creative/pragmatic/systematic (fallback)
+ - **Auto mode**: Select top 3 recommended professional roles
+ - **Manual mode**: AskUserQuestion with recommended roles + "Use simple perspectives" option
+
+3. **Initial Scoping Questions** (if new session + not auto mode)
+ - **Direction**: Multi-select from technical, UX, innovation, feasibility
+ - **Depth**: Single-select from quick/balanced/deep (15-20min / 30-60min / 1-2hr)
+ - **Constraints**: Multi-select from existing architecture, time, resources, or no constraints
+
+4. **Expand Seed into Exploration Vectors**
+ - Launch Gemini CLI with analysis mode
+ - Generate 5-7 exploration vectors:
+ - Core question: Fundamental problem/opportunity
+ - User perspective: Who benefits and how
+ - Technical angle: What enables this
+ - Alternative approaches: Other solutions
+ - Challenges: Potential blockers
+ - Innovation angle: 10x better approach
+ - Integration: Fit with existing systems
+ - Parse result into structured vectors
+
+**CLI Call Example**:
```javascript
-// See Configuration section for BRAINSTORM_DIMENSIONS definition
-
-function identifyDimensions(topic) {
- const text = topic.toLowerCase()
- const matched = []
-
- for (const [dimension, keywords] of Object.entries(BRAINSTORM_DIMENSIONS)) {
- if (keywords.some(k => text.includes(k))) {
- matched.push(dimension)
- }
- }
-
- // Default dimensions based on mode
- if (matched.length === 0) {
- return brainstormMode === 'creative'
- ? ['innovation', 'ux', 'technical']
- : ['technical', 'feasibility', 'business']
- }
-
- return matched
-}
-
-const dimensions = identifyDimensions(idea_or_topic)
-```
-
-**Step 1.2: Initial Scoping Questions**
-
-```javascript
-const autoYes = $ARGUMENTS.includes('--yes') || $ARGUMENTS.includes('-y')
-
-if (mode === 'new' && !autoYes) {
- AskUserQuestion({
- questions: [
- {
- question: `ๅคด่้ฃๆดไธป้ข: "${idea_or_topic}"\n\nๆจๅธๆๆข็ดขๅชไบๆนๅ?`,
- header: "ๆนๅ",
- multiSelect: true,
- options: [
- { label: "ๆๆฏๆนๆก", description: "ๆข็ดขๆๆฏๅฎ็ฐๅฏ่ฝๆง" },
- { label: "็จๆทไฝ้ช", description: "ไป็จๆท่งๅบฆๅบๅ" },
- { label: "ๅๆฐ็ช็ ด", description: "ๅฏปๆพ้ๅธธ่ง่งฃๅณๆนๆก" },
- { label: "ๅฏ่กๆง่ฏไผฐ", description: "่ฏไผฐๅฎ้
่ฝๅฐๅฏ่ฝ" }
- ]
- },
- {
- question: "ๅคด่้ฃๆดๆทฑๅบฆ?",
- header: "ๆทฑๅบฆ",
- multiSelect: false,
- options: [
- { label: "ๅฟซ้ๅๆฃ", description: "ๅนฟๅบฆไผๅ
๏ผๅฟซ้็ๆๅคไธชๆณๆณ (15-20ๅ้)" },
- { label: "ๅนณ่กกๆข็ดข", description: "ๆทฑๅบฆๅๅนฟๅบฆๅนณ่กก (30-60ๅ้)" },
- { label: "ๆทฑๅบฆๆๆ", description: "ๆทฑๅ
ฅๆข็ดขๅฐๆฐๆ ธๅฟๆณๆณ (1-2ๅฐๆถ)" }
- ]
- },
- {
- question: "ๆฏๅฆๆไปปไฝ็บฆๆๆๅฟ
้กป่่็ๅ ็ด ?",
- header: "็บฆๆ",
- multiSelect: true,
- options: [
- { label: "็ฐๆๆถๆ", description: "้่ฆไธ็ฐๆ็ณป็ปๅ
ผๅฎน" },
- { label: "ๆถ้ด้ๅถ", description: "ๆๅฎๆฝๆถ้ด็บฆๆ" },
- { label: "่ตๆบ้ๅถ", description: "ๅผๅ่ตๆบๆ้" },
- { label: "ๆ ็บฆๆ", description: "ๅฎๅ
จๅผๆพๆข็ดข" }
- ]
- }
- ]
- })
-}
-```
-
-**Step 1.3: Expand Seed into Exploration Vectors**
-
-```javascript
-// Generate exploration vectors from seed idea
-const expansionPrompt = `
-Given the initial idea: "${idea_or_topic}"
+Bash({
+ command: `ccw cli -p "
+Given the initial idea: '${idea_or_topic}'
User focus areas: ${userFocusAreas.join(', ')}
Constraints: ${constraints.join(', ')}
Generate 5-7 exploration vectors (questions/directions) to expand this idea:
-
1. Core question: What is the fundamental problem/opportunity?
2. User perspective: Who benefits and how?
3. Technical angle: What enables this technically?
@@ -220,46 +200,56 @@ Generate 5-7 exploration vectors (questions/directions) to expand this idea:
7. Integration: How does this fit with existing systems/processes?
Output as structured exploration vectors for multi-perspective analysis.
-`
-
-// โ ๏ธ CRITICAL: Must wait for CLI completion - do NOT proceed until result received
-const expansionResult = await Bash({
- command: `ccw cli -p "${expansionPrompt}" --tool gemini --mode analysis --model gemini-2.5-flash`,
+" --tool gemini --mode analysis --model gemini-2.5-flash`,
run_in_background: false
})
-
-const explorationVectors = parseExpansionResult(expansionResult)
```
-**Step 1.4: Create brainstorm.md**
+5. **Initialize brainstorm.md**
+ - Create brainstorm.md with session metadata
+ - Add initial context: user focus, depth, constraints
+ - Add seed expansion: original idea + exploration vectors
+ - Create empty sections for thought evolution timeline
-See **Templates** section for complete brainstorm.md structure. Initialize with:
-- Session metadata
-- Initial context (user focus, depth, constraints)
-- Seed expansion (original idea + exploration vectors)
-- Empty sections for thought evolution timeline
+**Success Criteria**:
+- Session folder created with brainstorm.md initialized
+- 1-3 roles selected (professional or simple perspectives)
+- 5-7 exploration vectors generated
+- User preferences captured (direction, depth, constraints)
----
+### Phase 2: Divergent Exploration
-### Phase 2: Divergent Exploration (cli-explore-agent + Multi-CLI)
+**Objective**: Gather codebase context, then execute multi-perspective analysis in parallel.
-**โ ๏ธ CRITICAL - EXPLORATION PRIORITY**:
-- **cli-explore-agent FIRST**: Always use cli-explore-agent as primary exploration method
-- **Multi-CLI AFTER**: Use Gemini/Codex/Claude only after cli-explore-agent provides context
-- **Sequential execution**: cli-explore-agent โ Multi-CLI perspectives (not all parallel)
-- Minimize output: No processing until 100% results available
+**Prerequisites**:
+- Phase 1 completed successfully
+- Roles selected and stored
+- brainstorm.md initialized
-**Step 2.1: Primary Exploration via cli-explore-agent**
+**Workflow Steps**:
+1. **Primary Codebase Exploration via cli-explore-agent** (โ ๏ธ FIRST)
+ - Agent type: `cli-explore-agent`
+ - Execution mode: synchronous (run_in_background: false)
+ - **Tasks**:
+ - Run: `ccw tool exec get_modules_by_depth '{}'`
+ - Search code related to topic keywords
+ - Read: `.workflow/project-tech.json` if exists
+ - **Output**: `{sessionFolder}/exploration-codebase.json`
+ - relevant_files: [{path, relevance, rationale}]
+ - existing_patterns: []
+ - architecture_constraints: []
+ - integration_points: []
+ - inspiration_sources: []
+ - **Purpose**: Enrich CLI prompts with codebase context
+
+**Agent Call Example**:
```javascript
-// โ ๏ธ PRIORITY: cli-explore-agent is the PRIMARY exploration method
-// MUST complete before any CLI calls
-
-const codebaseExploration = await Task(
- subagent_type="cli-explore-agent",
- run_in_background=false,
- description=`Explore codebase for brainstorm: ${topicSlug}`,
- prompt=`
+Task({
+ subagent_type: "cli-explore-agent",
+ run_in_background: false,
+ description: `Explore codebase for brainstorm: ${topicSlug}`,
+ prompt: `
## Brainstorm Context
Topic: ${idea_or_topic}
Dimensions: ${dimensions.join(', ')}
@@ -282,7 +272,7 @@ Write findings to: ${sessionFolder}/exploration-codebase.json
Schema:
{
- "relevant_files": [{path, relevance, rationale}],
+ "relevant_files": [{"path": "...", "relevance": "high|medium|low", "rationale": "..."}],
"existing_patterns": [],
"architecture_constraints": [],
"integration_points": [],
@@ -290,268 +280,195 @@ Schema:
"_metadata": { "exploration_type": "brainstorm-codebase", "timestamp": "..." }
}
`
-)
+})
-// Read exploration results for CLI context enrichment
-const explorationResults = Read(`${sessionFolder}/exploration-codebase.json`)
-```
-
-**Step 2.2: Multi-CLI Perspectives (Using Exploration Context)**
+2. **Multi-CLI Perspective Analysis** (โ ๏ธ AFTER exploration)
+ - Launch 3 CLI calls in parallel (Gemini/Codex/Claude)
+ - **Perspectives**:
+ - **Creative (Gemini)**: Innovation, cross-domain inspiration, challenge assumptions
+ - **Pragmatic (Codex)**: Implementation reality, feasibility, technical blockers
+ - **Systematic (Claude)**: Architecture, decomposition, scalability
+ - **Shared context**: Include exploration-codebase.json findings in prompts
+ - **Execution**: Bash with run_in_background: true, wait for all results
+ - **Output**: perspectives.json with creative/pragmatic/systematic sections
+**Multi-CLI Call Example** (parallel execution):
```javascript
-// ============================================
-// Perspective Configuration (Data-Driven)
-// ============================================
-const PERSPECTIVES = {
- creative: {
- tool: 'gemini',
- focus: 'generate innovative, unconventional ideas',
- success: '5+ unique creative solutions that push boundaries',
- tasks: [
- 'Build on existing patterns found - how can they be extended creatively?',
- 'Think beyond obvious solutions - what would be surprising/delightful?',
- 'Explore cross-domain inspiration (what can we learn from other industries?)',
- 'Challenge assumptions - what if the opposite were true?',
- 'Generate "moonshot" ideas alongside practical ones'
- ],
- expected: [
- '5+ creative ideas with brief descriptions',
- 'Each idea rated: novelty (1-5), potential impact (1-5)',
- 'Key assumptions challenged',
- 'Cross-domain inspirations',
- 'One "crazy" idea that might just work'
- ],
- constraints: mode => mode === 'structured' ? 'Keep ideas technically feasible' : 'No constraints - think freely'
- },
-
- pragmatic: {
- tool: 'codex',
- focus: 'focus on implementation reality',
- success: 'Actionable approaches with clear implementation paths',
- tasks: [
- 'Build on explored codebase - how to integrate with existing patterns?',
- 'Evaluate technical feasibility of core concept',
- 'Identify existing patterns/libraries that could help',
- 'Estimate implementation complexity',
- 'Highlight potential technical blockers',
- 'Suggest incremental implementation approach'
- ],
- expected: [
- '3-5 practical implementation approaches',
- 'Each rated: effort (1-5), risk (1-5), reuse potential (1-5)',
- 'Technical dependencies identified',
- 'Quick wins vs long-term solutions',
- 'Recommended starting point'
- ],
- constraints: () => 'Focus on what can actually be built with current tech stack'
- },
-
- systematic: {
- tool: 'claude',
- focus: 'architectural and structural thinking',
- success: 'Well-structured solution framework with clear tradeoffs',
- tasks: [
- 'Build on explored architecture - how to extend systematically?',
- 'Decompose the problem into sub-problems',
- 'Identify architectural patterns that apply',
- 'Map dependencies and interactions',
- 'Consider scalability implications',
- 'Propose systematic solution structure'
- ],
- expected: [
- 'Problem decomposition diagram (text)',
- '2-3 architectural approaches with tradeoffs',
- 'Dependency mapping',
- 'Scalability assessment',
- 'Recommended architecture pattern',
- 'Risk matrix'
- ],
- constraints: () => 'Consider existing system architecture'
- }
-}
-
-// ============================================
-// Shared Context Builder
-// ============================================
-const buildExplorationContext = (results) => `
+// Build shared context from exploration results
+const explorationContext = `
PRIOR EXPLORATION CONTEXT (from cli-explore-agent):
-- Key files: ${results.relevant_files.slice(0,5).map(f => f.path).join(', ')}
-- Existing patterns: ${results.existing_patterns.slice(0,3).join(', ')}
-- Architecture constraints: ${results.architecture_constraints.slice(0,3).join(', ')}
-- Integration points: ${results.integration_points.slice(0,3).join(', ')}`
+- Key files: ${explorationResults.relevant_files.slice(0,5).map(f => f.path).join(', ')}
+- Existing patterns: ${explorationResults.existing_patterns.slice(0,3).join(', ')}
+- Architecture constraints: ${explorationResults.architecture_constraints.slice(0,3).join(', ')}
+- Integration points: ${explorationResults.integration_points.slice(0,3).join(', ')}`
-// ============================================
-// Universal CLI Prompt Template
-// ============================================
-const buildCLIPrompt = (perspective, config) => `
-PURPOSE: ${perspective.charAt(0).toUpperCase() + perspective.slice(1)} brainstorming for '${idea_or_topic}' - ${config.focus}
-Success: ${config.success}
+// Launch 3 CLI calls in parallel (single message, multiple Bash calls)
+Bash({
+ command: `ccw cli -p "
+PURPOSE: Creative brainstorming for '${idea_or_topic}' - generate innovative ideas
+Success: 5+ unique creative solutions that push boundaries
-${buildExplorationContext(explorationResults)}
+${explorationContext}
TASK:
-${config.tasks.map(t => `โข ${t}`).join('\n')}
+โข Build on existing patterns - how can they be extended creatively?
+โข Think beyond obvious solutions - what would be surprising/delightful?
+โข Explore cross-domain inspiration
+โข Challenge assumptions - what if the opposite were true?
+โข Generate 'moonshot' ideas alongside practical ones
MODE: analysis
-
CONTEXT: @**/* | Topic: ${idea_or_topic}
-Exploration vectors: ${explorationVectors.map(v => v.title).join(', ')}
+EXPECTED: 5+ creative ideas with novelty/impact ratings, challenged assumptions, cross-domain inspirations
+CONSTRAINTS: ${brainstormMode === 'structured' ? 'Keep ideas technically feasible' : 'No constraints - think freely'}
+" --tool gemini --mode analysis`,
+ run_in_background: true
+})
-EXPECTED:
-${config.expected.map(e => `- ${e}`).join('\n')}
+Bash({
+ command: `ccw cli -p "
+PURPOSE: Pragmatic brainstorming for '${idea_or_topic}' - focus on implementation reality
+Success: Actionable approaches with clear implementation paths
-CONSTRAINTS: ${config.constraints(brainstormMode)}`
+${explorationContext}
-// ============================================
-// Launch Multi-CLI (Parallel)
-// ============================================
-const cliPromises = Object.entries(PERSPECTIVES).map(([name, config]) =>
- Bash({
- command: `ccw cli -p "${buildCLIPrompt(name, config)}" --tool ${config.tool} --mode analysis`,
- run_in_background: true
- })
-)
+TASK:
+โข Build on explored codebase - how to integrate with existing patterns?
+โข Evaluate technical feasibility of core concept
+โข Identify existing patterns/libraries that could help
+โข Estimate implementation complexity
+โข Highlight potential technical blockers
+โข Suggest incremental implementation approach
-// โ ๏ธ CRITICAL: Must wait for ALL results - do NOT proceed until all CLIs complete
-await Promise.all(cliPromises)
+MODE: analysis
+CONTEXT: @**/* | Topic: ${idea_or_topic}
+EXPECTED: 3-5 practical approaches with effort/risk ratings, dependencies, quick wins vs long-term
+CONSTRAINTS: Focus on what can actually be built with current tech stack
+" --tool codex --mode analysis`,
+ run_in_background: true
+})
+
+Bash({
+ command: `ccw cli -p "
+PURPOSE: Systematic brainstorming for '${idea_or_topic}' - architectural thinking
+Success: Well-structured solution framework with clear tradeoffs
+
+${explorationContext}
+
+TASK:
+โข Build on explored architecture - how to extend systematically?
+โข Decompose the problem into sub-problems
+โข Identify architectural patterns that apply
+โข Map dependencies and interactions
+โข Consider scalability implications
+โข Propose systematic solution structure
+
+MODE: analysis
+CONTEXT: @**/* | Topic: ${idea_or_topic}
+EXPECTED: Problem decomposition, 2-3 architectural approaches with tradeoffs, scalability assessment
+CONSTRAINTS: Consider existing system architecture
+" --tool claude --mode analysis`,
+ run_in_background: true
+})
+
+// โ ๏ธ STOP POINT: Wait for hook callback to receive all results before continuing
```
-**โ ๏ธ STOP POINT**: After launching CLI calls, stop output immediately. Wait for hook callback to receive results before continuing to Step 2.3.
+3. **Aggregate Multi-Perspective Findings**
+ - Consolidate creative/pragmatic/systematic results
+ - Extract synthesis:
+ - Convergent themes (all agree)
+ - Conflicting views (need resolution)
+ - Unique contributions (perspective-specific insights)
+ - Write to perspectives.json
-**Step 2.3: Aggregate Multi-Perspective Findings**
+4. **Update brainstorm.md**
+ - Append Round 2 section with multi-perspective exploration
+ - Include creative/pragmatic/systematic findings
+ - Add perspective synthesis
+**CLI Prompt Template**:
+- **PURPOSE**: Role brainstorming for topic - focus description
+- **TASK**: Bullet list of specific actions
+- **MODE**: analysis
+- **CONTEXT**: @**/* | Topic + Exploration vectors + Codebase findings
+- **EXPECTED**: Output format requirements
+- **CONSTRAINTS**: Role-specific constraints
+
+**Success Criteria**:
+- exploration-codebase.json created with codebase context
+- perspectives.json created with 3 perspective analyses
+- brainstorm.md updated with Round 2 findings
+- All CLI calls completed successfully
+
+### Phase 3: Interactive Refinement
+
+**Objective**: Iteratively refine ideas through user-guided exploration cycles.
+
+**Prerequisites**:
+- Phase 2 completed successfully
+- perspectives.json contains initial ideas
+- brainstorm.md has Round 2 findings
+
+**Workflow Steps**:
+
+1. **Present Current State**
+ - Extract top ideas from perspectives.json
+ - Display with: title, source, brief description, novelty/feasibility ratings
+ - List open questions
+
+2. **Gather User Direction** (AskUserQuestion)
+ - **Question 1**: Which ideas to explore (multi-select from top ideas)
+ - **Question 2**: Next step (single-select):
+ - **ๆทฑๅ
ฅๆข็ดข**: Deep dive on selected ideas
+ - **็ปง็ปญๅๆฃ**: Generate more ideas
+ - **ๆๆ้ช่ฏ**: Devil's advocate challenge
+ - **ๅๅนถ็ปผๅ**: Merge multiple ideas
+ - **ๅๅคๆถๆ**: Begin convergence (exit loop)
+
+3. **Execute User-Selected Action**
+
+ **Deep Dive** (per selected idea):
+ - Launch Gemini CLI with analysis mode
+ - Tasks: Elaborate concept, implementation requirements, challenges, POC approach, metrics, dependencies
+ - Output: `{sessionFolder}/ideas/{idea-slug}.md`
+
+ **Generate More Ideas**:
+ - Launch CLI with new angles from unexplored vectors
+ - Add results to perspectives.json
+
+ **Devil's Advocate Challenge**:
+ - Launch Codex CLI with analysis mode
+ - Tasks: Identify objections, challenge assumptions, failure scenarios, alternatives, survivability rating
+ - Return challenge results for idea strengthening
+
+ **Merge Ideas**:
+ - Launch Gemini CLI with analysis mode
+ - Tasks: Identify complementary elements, resolve contradictions, create unified concept
+ - Add merged idea to perspectives.json
+
+4. **Update brainstorm.md**
+ - Append Round N section with findings
+ - Document user direction and action results
+
+5. **Repeat or Converge**
+ - Continue loop (max 6 rounds) or exit to Phase 4
+
+**Refinement Actions**:
+
+| Action | Tool | Output | Description |
+|--------|------|--------|-------------|
+| Deep Dive | Gemini CLI | ideas/{slug}.md | Comprehensive idea analysis |
+| Generate More | Selected CLI | Updated perspectives.json | Additional idea generation |
+| Challenge | Codex CLI | Challenge results | Critical weaknesses exposed |
+| Merge | Gemini CLI | Merged idea | Synthesized concept |
+
+**CLI Call Examples for Refinement Actions**:
+
+**1. Deep Dive on Selected Idea**:
```javascript
-const perspectives = {
- session_id: sessionId,
- timestamp: getUtc8ISOString(),
- topic: idea_or_topic,
-
- creative: {
- source: 'gemini',
- ideas: [...],
- insights: [...],
- challenges: [...]
- },
-
- pragmatic: {
- source: 'codex',
- approaches: [...],
- blockers: [...],
- recommendations: [...]
- },
-
- systematic: {
- source: 'claude',
- decomposition: [...],
- patterns: [...],
- tradeoffs: [...]
- },
-
- synthesis: {
- convergent_themes: [],
- conflicting_views: [],
- unique_contributions: []
- }
-}
-
-Write(perspectivesPath, JSON.stringify(perspectives, null, 2))
-```
-
-**Step 2.4: Update brainstorm.md with Perspectives**
-
-Append to brainstorm.md the Round 2 multi-perspective exploration findings (see Templates section for format).
-
----
-
-### Phase 3: Interactive Refinement (Multi-Round)
-
-**Step 3.1: Present & Select Directions**
-
-```javascript
-const MAX_ROUNDS = 6
-let roundNumber = 3 // After initial exploration
-let brainstormComplete = false
-
-while (!brainstormComplete && roundNumber <= MAX_ROUNDS) {
-
- // Present current state
- console.log(`
-## Brainstorm Round ${roundNumber}
-
-### Top Ideas So Far
-
-${topIdeas.map((idea, i) => `
-${i+1}. **${idea.title}** (${idea.source})
- ${idea.brief}
- - Novelty: ${'โญ'.repeat(idea.novelty)} | Feasibility: ${'โ
'.repeat(idea.feasibility)}
-`).join('\n')}
-
-### Open Questions
-${openQuestions.map((q, i) => `${i+1}. ${q}`).join('\n')}
-`)
-
- // Gather user direction
- const userDirection = AskUserQuestion({
- questions: [
- {
- question: "ๅชไบๆณๆณๅผๅพๆทฑๅ
ฅๆข็ดข?",
- header: "้ๆฉ",
- multiSelect: true,
- options: topIdeas.slice(0, 4).map(idea => ({
- label: idea.title,
- description: idea.brief
- }))
- },
- {
- question: "ไธไธๆญฅ?",
- header: "ๆนๅ",
- multiSelect: false,
- options: [
- { label: "ๆทฑๅ
ฅๆข็ดข", description: "ๆทฑๅ
ฅๅๆ้ไธญ็ๆณๆณ" },
- { label: "็ปง็ปญๅๆฃ", description: "็ๆๆดๅคๆฐๆณๆณ" },
- { label: "ๆๆ้ช่ฏ", description: "Devil's advocate - ๆๆๅฝๅๆณๆณ" },
- { label: "ๅๅนถ็ปผๅ", description: "ๅฐ่ฏๅๅนถๅคไธชๆณๆณ" },
- { label: "ๅๅคๆถๆ", description: "ๅผๅงๆด็ๆ็ป็ป่ฎบ" }
- ]
- }
- ]
- })
-
- // Process based on direction
- switch (userDirection.direction) {
- case "ๆทฑๅ
ฅๆข็ดข":
- await deepDiveIdeas(userDirection.selectedIdeas)
- break
- case "็ปง็ปญๅๆฃ":
- await generateMoreIdeas()
- break
- case "ๆๆ้ช่ฏ":
- await devilsAdvocate(topIdeas)
- break
- case "ๅๅนถ็ปผๅ":
- await mergeIdeas(userDirection.selectedIdeas)
- break
- case "ๅๅคๆถๆ":
- brainstormComplete = true
- break
- }
-
- // Update brainstorm.md
- updateBrainstormDocument(roundNumber, userDirection, findings)
- roundNumber++
-}
-```
-
-**Step 3.2: Deep Dive on Selected Ideas**
-
-```javascript
-async function deepDiveIdeas(selectedIdeas) {
- for (const idea of selectedIdeas) {
- const ideaPath = `${ideasFolder}/${idea.slug}.md`
-
- // โ ๏ธ CRITICAL: Must wait for CLI completion before saving results
- await Bash({
- command: `ccw cli -p "
+Bash({
+ command: `ccw cli -p "
PURPOSE: Deep dive analysis on idea '${idea.title}'
Success: Comprehensive understanding with actionable next steps
@@ -580,21 +497,14 @@ EXPECTED:
CONSTRAINTS: Focus on actionability
" --tool gemini --mode analysis`,
- run_in_background: false
- })
-
- Write(ideaPath, deepDiveContent)
- }
-}
+ run_in_background: false
+})
```
-**Step 3.3: Devil's Advocate Challenge**
-
+**2. Devil's Advocate Challenge**:
```javascript
-async function devilsAdvocate(ideas) {
- // โ ๏ธ CRITICAL: Must wait for CLI completion before returning results
- const challengeResult = await Bash({
- command: `ccw cli -p "
+Bash({
+ command: `ccw cli -p "
PURPOSE: Devil's advocate - rigorously challenge these brainstorm ideas
Success: Uncover hidden weaknesses and strengthen viable ideas
@@ -620,22 +530,14 @@ EXPECTED:
CONSTRAINTS: Be genuinely critical, not just contrarian
" --tool codex --mode analysis`,
- run_in_background: false
- })
-
- return challengeResult
-}
+ run_in_background: false
+})
```
-**Step 3.4: Merge & Synthesize Ideas**
-
+**3. Merge Multiple Ideas**:
```javascript
-async function mergeIdeas(ideaIds) {
- const selectedIdeas = ideas.filter(i => ideaIds.includes(i.id))
-
- // โ ๏ธ CRITICAL: Must wait for CLI completion before processing merge result
- const mergeResult = await Bash({
- command: `ccw cli -p "
+Bash({
+ command: `ccw cli -p "
PURPOSE: Synthesize multiple ideas into unified concept
Success: Coherent merged idea that captures best elements
@@ -665,390 +567,183 @@ EXPECTED:
CONSTRAINTS: Don't force incompatible ideas together
" --tool gemini --mode analysis`,
- run_in_background: false
- })
-
- const mergedIdea = parseMergeResult(mergeResult)
- ideas.push(mergedIdea)
-
- return mergedIdea
-}
+ run_in_background: false
+})
```
-**Step 3.5: Document Each Round**
-
-Append each round's findings to brainstorm.md (see Templates section for format).
-
----
+**Success Criteria**:
+- User-selected ideas processed
+- brainstorm.md updated with all refinement rounds
+- ideas/ folder contains deep-dive documents for selected ideas
+- Exit condition reached (user selects "ๅๅคๆถๆ" or max rounds)
### Phase 4: Convergence & Crystallization
-**Step 4.1: Final Synthesis**
+**Objective**: Synthesize final ideas, generate conclusions, offer next steps.
-```javascript
-const synthesis = {
- session_id: sessionId,
- topic: idea_or_topic,
- completed: getUtc8ISOString(),
- total_rounds: roundNumber,
+**Prerequisites**:
+- Phase 3 completed successfully
+- Multiple rounds of refinement documented
+- User ready to converge
- top_ideas: ideas.filter(i => i.status === 'active').sort((a,b) => b.score - a.score).slice(0, 5).map(idea => ({
- title: idea.title,
- description: idea.description,
- source_perspective: idea.source,
- score: idea.score,
- novelty: idea.novelty,
- feasibility: idea.feasibility,
- key_strengths: idea.strengths,
- main_challenges: idea.challenges,
- next_steps: idea.nextSteps
- })),
+**Workflow Steps**:
- parked_ideas: ideas.filter(i => i.status === 'parked').map(idea => ({
- title: idea.title,
- reason_parked: idea.parkReason,
- potential_future_trigger: idea.futureTrigger
- })),
+1. **Generate Final Synthesis**
+ - Consolidate all ideas from perspectives.json and refinement rounds
+ - **Top ideas**: Filter active ideas, sort by score, take top 5
+ - Include: title, description, source_perspective, score, novelty, feasibility, strengths, challenges, next_steps
+ - **Parked ideas**: Ideas marked as parked with reason and future trigger
+ - **Key insights**: Process discoveries, challenged assumptions, unexpected connections
+ - **Recommendations**: Primary recommendation, alternatives, not recommended
+ - **Follow-up**: Implementation/research/validation summaries
+ - Write to synthesis.json
- key_insights: keyInsights,
+2. **Final brainstorm.md Update**
+ - Append synthesis & conclusions section
+ - **Executive summary**: High-level overview
+ - **Top ideas**: Ranked with descriptions, strengths, challenges, next steps
+ - **Primary recommendation**: Best path forward with rationale
+ - **Alternative approaches**: Other viable options with tradeoffs
+ - **Parked ideas**: Future considerations
+ - **Key insights**: Learnings from the process
+ - **Session statistics**: Rounds, ideas generated/survived, duration
- recommendations: {
- primary: primaryRecommendation,
- alternatives: alternativeApproaches,
- not_recommended: notRecommended
- },
+3. **Post-Completion Options** (AskUserQuestion)
+ - **ๅๅปบๅฎๆฝ่ฎกๅ**: Launch workflow:plan with top idea
+ - **ๅๅปบIssue**: Launch issue:new for top 3 ideas
+ - **ๆทฑๅ
ฅๅๆ**: Launch workflow:analyze-with-file for top idea
+ - **ๅฏผๅบๅไบซ**: Generate shareable report
+ - **ๅฎๆ**: No further action
- follow_up: [
- { type: 'implementation', summary: '...' },
- { type: 'research', summary: '...' },
- { type: 'validation', summary: '...' }
- ]
-}
+**synthesis.json Schema**:
+- `session_id`: Session identifier
+- `topic`: Original idea/topic
+- `completed`: Completion timestamp
+- `total_rounds`: Number of refinement rounds
+- `top_ideas[]`: Top 5 ranked ideas
+- `parked_ideas[]`: Ideas parked for future
+- `key_insights[]`: Process learnings
+- `recommendations`: Primary/alternatives/not_recommended
+- `follow_up[]`: Next step summaries
-Write(synthesisPath, JSON.stringify(synthesis, null, 2))
-```
-
-**Step 4.2: Final brainstorm.md Update**
-
-Update brainstorm.md with synthesis & conclusions (see Templates section for format).
-
-**Step 4.3: Post-Completion Options**
-
-```javascript
-AskUserQuestion({
- questions: [{
- question: "ๅคด่้ฃๆดๅฎๆใๆฏๅฆ้่ฆๅ็ปญๆไฝ?",
- header: "ๅ็ปญ",
- multiSelect: true,
- options: [
- { label: "ๅๅปบๅฎๆฝ่ฎกๅ", description: "ๅฐๆไฝณๆณๆณ่ฝฌไธบๅฎๆฝ่ฎกๅ" },
- { label: "ๅๅปบIssue", description: "ๅฐๆณๆณ่ฝฌไธบๅฏ่ฟฝ่ธช็Issue" },
- { label: "ๆทฑๅ
ฅๅๆ", description: "ๅฏนๆไธชๆณๆณ่ฟ่กๆทฑๅบฆๆๆฏๅๆ" },
- { label: "ๅฏผๅบๅไบซ", description: "็ๆๅฏๅไบซ็ๆฅๅ" },
- { label: "ๅฎๆ", description: "ไธ้่ฆๅ็ปญๆไฝ" }
- ]
- }]
-})
-
-// Handle selections
-if (selection.includes("ๅๅปบๅฎๆฝ่ฎกๅ")) {
- const topIdea = synthesis.top_ideas[0]
- Skill(skill="workflow:plan", args=`ๅฎๆฝ: ${topIdea.title} - ${topIdea.description}`)
-}
-if (selection.includes("ๅๅปบIssue")) {
- for (const idea of synthesis.top_ideas.slice(0, 3)) {
- Skill(skill="issue:new", args=`${idea.title}: ${idea.next_steps[0]}`)
- }
-}
-if (selection.includes("ๆทฑๅ
ฅๅๆ")) {
- Skill(skill="workflow:analyze-with-file", args=synthesis.top_ideas[0].title)
-}
-if (selection.includes("ๅฏผๅบๅไบซ")) {
- exportBrainstormReport(sessionFolder)
-}
-```
-
----
+**Success Criteria**:
+- synthesis.json created with final synthesis
+- brainstorm.md finalized with conclusions
+- User offered next step options
+- Session complete
## Configuration
### Brainstorm Dimensions
-```javascript
-const BRAINSTORM_DIMENSIONS = {
- technical: ['ๆๆฏ', 'technical', 'implementation', 'code', 'ๅฎ็ฐ', 'architecture'],
- ux: ['็จๆท', 'user', 'experience', 'UX', 'UI', 'ไฝ้ช', 'interaction'],
- business: ['ไธๅก', 'business', 'value', 'ROI', 'ไปทๅผ', 'market'],
- innovation: ['ๅๆฐ', 'innovation', 'novel', 'creative', 'ๆฐ้ข'],
- feasibility: ['ๅฏ่ก', 'feasible', 'practical', 'realistic', 'ๅฎ้
'],
- scalability: ['ๆฉๅฑ', 'scale', 'growth', 'performance', 'ๆง่ฝ'],
- security: ['ๅฎๅ
จ', 'security', 'risk', 'protection', '้ฃ้ฉ']
-}
-```
+Dimensions matched against topic keywords to identify focus areas:
-### Multi-CLI Collaboration Strategy
+| Dimension | Keywords |
+|-----------|----------|
+| technical | ๆๆฏ, technical, implementation, code, ๅฎ็ฐ, architecture |
+| ux | ็จๆท, user, experience, UX, UI, ไฝ้ช, interaction |
+| business | ไธๅก, business, value, ROI, ไปทๅผ, market |
+| innovation | ๅๆฐ, innovation, novel, creative, ๆฐ้ข |
+| feasibility | ๅฏ่ก, feasible, practical, realistic, ๅฎ้
|
+| scalability | ๆฉๅฑ, scale, growth, performance, ๆง่ฝ |
+| security | ๅฎๅ
จ, security, risk, protection, ้ฃ้ฉ |
-**Perspective Roles**
+### Role Selection
-| CLI | Role | Focus | Best For |
-|-----|------|-------|----------|
-| Gemini | Creative | Innovation, cross-domain | Generating novel ideas |
-| Codex | Pragmatic | Implementation, feasibility | Reality-checking ideas |
-| Claude | Systematic | Architecture, structure | Organizing solutions |
+**Professional Roles** (recommended based on topic keywords):
-**Collaboration Patterns**
+| Role | CLI Tool | Focus Area | Keywords |
+|------|----------|------------|----------|
+| system-architect | Claude | Architecture, patterns | ๆถๆ, architecture, system, ็ณป็ป, design pattern |
+| product-manager | Gemini | Business value, roadmap | ไบงๅ, product, feature, ๅ่ฝ, roadmap |
+| ui-designer | Gemini | Visual design, interaction | UI, ็้ข, interface, visual, ่ง่ง |
+| ux-expert | Codex | User research, usability | UX, ไฝ้ช, experience, user, ็จๆท |
+| data-architect | Claude | Data modeling, storage | ๆฐๆฎ, data, database, ๅญๅจ, storage |
+| test-strategist | Codex | Quality, testing | ๆต่ฏ, test, quality, ่ดจ้, QA |
+| subject-matter-expert | Gemini | Domain knowledge | ้ขๅ, domain, industry, ่กไธ, expert |
+| product-owner | Codex | Priority, scope | ไผๅ
, priority, scope, ่ๅด, backlog |
+| scrum-master | Gemini | Process, collaboration | ๆๆท, agile, scrum, sprint, ่ฟญไปฃ |
-1. **Parallel Divergence**: All CLIs explore simultaneously from different angles
-2. **Sequential Deep-Dive**: One CLI expands, others critique/refine
-3. **Debate Mode**: CLIs argue for/against specific approaches
-4. **Synthesis Mode**: Combine insights from all perspectives
+**Simple Perspectives** (fallback):
-**When to Use Each Pattern**
+| Perspective | CLI Tool | Focus | Best For |
+|-------------|----------|-------|----------|
+| creative | Gemini | Innovation, cross-domain | Generating novel ideas |
+| pragmatic | Codex | Implementation, feasibility | Reality-checking ideas |
+| systematic | Claude | Architecture, structure | Organizing solutions |
-- **New topic**: Parallel Divergence โ get diverse initial ideas
-- **Promising idea**: Sequential Deep-Dive โ thorough exploration
-- **Controversial approach**: Debate Mode โ uncover hidden issues
-- **Ready to decide**: Synthesis Mode โ create actionable conclusion
+**Selection Strategy**:
+1. **Auto mode** (`-y`): Choose top 3 recommended professional roles
+2. **Manual mode**: Present recommended roles + "Use simple perspectives" option
+3. **Continue mode**: Use roles from previous session
-### Error Handling
+### Collaboration Patterns
-| Situation | Action |
-|-----------|--------|
-| CLI timeout | Retry with shorter prompt, or continue without that perspective |
-| No good ideas | Reframe the problem, adjust constraints, try different angles |
-| User disengaged | Summarize progress, offer break point with resume option |
-| Perspectives conflict | Present as tradeoff, let user decide direction |
+| Pattern | Usage | Description |
+|---------|-------|-------------|
+| Parallel Divergence | New topic | All roles explore simultaneously from different angles |
+| Sequential Deep-Dive | Promising idea | One role expands, others critique/refine |
+| Debate Mode | Controversial approach | Roles argue for/against approaches |
+| Synthesis Mode | Ready to decide | Combine insights into actionable conclusion |
+
+### Context Overflow Protection
+
+**Per-Role Limits**:
+- Main analysis output: < 3000 words
+- Sub-document (if any): < 2000 words each
+- Maximum sub-documents: 5 per role
+
+**Synthesis Protection**:
+- If total analysis > 100KB, synthesis reads only main analysis files (not sub-documents)
+- Large ideas automatically split into separate idea documents in ideas/ folder
+
+**Recovery Steps**:
+1. Check CLI logs for context overflow errors
+2. Reduce scope: fewer roles or simpler topic
+3. Use `--mode structured` for more focused output
+4. Split complex topics into multiple sessions
+
+**Prevention**:
+- Start with 3 roles (default), increase if needed
+- Use structured topic format: "GOAL: ... SCOPE: ... CONTEXT: ..."
+- Review output sizes before final synthesis
+
+## Error Handling
+
+| Error | Resolution |
+|-------|------------|
+| cli-explore-agent fails | Continue with empty exploration context |
+| CLI timeout | Retry with shorter prompt, or skip perspective |
+| No good ideas | Reframe problem, adjust constraints, try new angles |
+| User disengaged | Summarize progress, offer break point with resume |
+| Perspectives conflict | Present as tradeoff, let user decide |
| Max rounds reached | Force synthesis, highlight unresolved questions |
| All ideas fail challenge | Return to divergent phase with new constraints |
----
+## Best Practices
+
+1. **Clear Topic Definition**: Detailed topics โ better role selection and exploration
+2. **Review brainstorm.md**: Check thought evolution before final decisions
+3. **Embrace Conflicts**: Perspective conflicts often reveal important tradeoffs
+4. **Document Evolution**: brainstorm.md captures full thinking process for team review
+5. **Use Continue Mode**: Resume sessions to build on previous exploration
## Templates
-### Session Folder Structure
-
-```
-.workflow/.brainstorm/BS-{slug}-{date}/
-โโโ brainstorm.md # Complete thought evolution
-โโโ perspectives.json # Multi-CLI perspective findings
-โโโ synthesis.json # Final synthesis
-โโโ ideas/ # Individual idea deep-dives
- โโโ idea-1.md
- โโโ idea-2.md
- โโโ merged-idea-1.md
-```
-
-### Brainstorm Document Template
-
-```markdown
-# Brainstorm Session
-
-**Session ID**: BS-xxx-YYYY-MM-DD
-**Topic**: [idea or topic]
-**Started**: YYYY-MM-DDTHH:mm:ss+08:00
-**Mode**: creative | structured | balanced
-**Dimensions**: [technical, ux, innovation, ...]
-
----
-
-## Initial Context
-
-**User Focus**: [selected focus areas]
-**Depth**: [quick|balanced|deep]
-**Constraints**: [if any]
-
----
-
-## Seed Expansion
-
-### Original Idea
-> [the initial idea]
-
-### Exploration Vectors
-
-#### Vector 1: [title]
-**Question**: [question]
-**Angle**: [angle]
-**Potential**: [potential]
-
-[... more vectors ...]
-
----
-
-## Thought Evolution Timeline
-
-### Round 1 - Seed Understanding (timestamp)
-
-#### Initial Parsing
-- **Core concept**: [concept]
-- **Problem space**: [space]
-- **Opportunity**: [opportunity]
-
-#### Key Questions to Explore
-1. [question 1]
-2. [question 2]
-...
-
----
-
-### Round 2 - Multi-Perspective Exploration (timestamp)
-
-#### Creative Perspective (Gemini)
-
-**Top Creative Ideas**:
-1. **[Title]** โญ Novelty: X/5 | Impact: Y/5
- [description]
-
-**Challenged Assumptions**:
-- ~~[assumption]~~ โ Consider: [alternative]
-
-**Cross-Domain Inspirations**:
-- [inspiration]
-
----
-
-#### Pragmatic Perspective (Codex)
-
-**Implementation Approaches**:
-1. **[Title]** | Effort: X/5 | Risk: Y/5
- [description]
- - Quick win: [win]
- - Dependencies: [deps]
-
-**Technical Blockers**:
-- โ ๏ธ [blocker]
-
----
-
-#### Systematic Perspective (Claude)
-
-**Problem Decomposition**:
-[decomposition]
-
-**Architectural Options**:
-1. **[Pattern]**
- - Pros: [pros]
- - Cons: [cons]
- - Best for: [context]
-
----
-
-#### Perspective Synthesis
-
-**Convergent Themes** (all perspectives agree):
-- โ
[theme]
-
-**Conflicting Views** (need resolution):
-- ๐ [topic]
- - Creative: [view]
- - Pragmatic: [view]
- - Systematic: [view]
-
-**Unique Contributions**:
-- ๐ก [source] [insight]
-
----
-
-### Round 3+ - [Round Type] (timestamp)
-
-[Round-specific content: deep-dive, challenge, merge, etc.]
-
----
-
-## Synthesis & Conclusions (timestamp)
-
-### Executive Summary
-
-[summary]
-
-### Top Ideas (Final Ranking)
-
-#### 1. [Title] โญ Score: X/10
-
-**Description**: [description]
-
-**Why This Idea**:
-- โ
[strength]
-
-**Main Challenges**:
-- โ ๏ธ [challenge]
-
-**Recommended Next Steps**:
-1. [step]
-2. [step]
-
----
-
-[... more ideas ...]
-
-### Primary Recommendation
-
-> [recommendation]
-
-**Rationale**: [rationale]
-
-**Quick Start Path**:
-1. [step]
-2. [step]
-3. [step]
-
-### Alternative Approaches
-
-1. **[Title]**
- - When to consider: [when]
- - Tradeoff: [tradeoff]
-
-### Ideas Parked for Future
-
-- **[Title]** (Parked: [reason])
- - Revisit when: [trigger]
-
----
-
-## Key Insights
-
-### Process Discoveries
-
-- ๐ก [discovery]
-
-### Assumptions Challenged
-
-- ~~[original]~~ โ [updated]
-
-### Unexpected Connections
-
-- ๐ [connection]
-
----
-
-## Current Understanding (Final)
-
-### Problem Reframed
-
-[reframed problem]
-
-### Solution Space Mapped
-
-[solution space]
-
-### Decision Framework
-
-When to choose each approach:
-[framework]
-
----
-
-## Session Statistics
-
-- **Total Rounds**: [n]
-- **Ideas Generated**: [n]
-- **Ideas Survived**: [n]
-- **Perspectives Used**: Gemini (creative), Codex (pragmatic), Claude (systematic)
-- **Duration**: [duration]
-- **Artifacts**: brainstorm.md, perspectives.json, synthesis.json, [n] idea deep-dives
-```
-
----
+### Brainstorm Document Structure
+
+**brainstorm.md** contains:
+- **Header**: Session metadata (ID, topic, started, mode, dimensions)
+- **Initial Context**: User focus, depth, constraints
+- **Seed Expansion**: Original idea + exploration vectors
+- **Thought Evolution Timeline**: Round-by-round findings
+ - Round 1: Seed Understanding
+ - Round 2: Multi-Perspective Exploration (creative/pragmatic/systematic)
+ - Round 3-N: Interactive Refinement (deep-dive/challenge/merge)
+- **Synthesis & Conclusions**: Executive summary, top ideas, recommendations
+- **Session Statistics**: Rounds, ideas, duration, artifacts
+
+See full markdown template in original file (lines 955-1161).
## Usage Recommendations
@@ -1069,3 +764,7 @@ When to choose each approach:
- Direction is already clear
- Ready to move from ideas to execution
- Need implementation breakdown
+
+---
+
+**Now execute brainstorm-with-file for**: $ARGUMENTS
diff --git a/.codex/prompts/merge-plans-with-file.md b/.codex/prompts/merge-plans-with-file.md
deleted file mode 100644
index 4d70fc49..00000000
--- a/.codex/prompts/merge-plans-with-file.md
+++ /dev/null
@@ -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=\"\" [--rule=consensus|priority|hierarchy] [--output=] [--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
diff --git a/ccw/frontend/src/components/codexlens/AdvancedTab.tsx b/ccw/frontend/src/components/codexlens/AdvancedTab.tsx
new file mode 100644
index 00000000..23e0a798
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/AdvancedTab.tsx
@@ -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({});
+ 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 => {
+ const envObj: Record = {};
+ 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 (
+
+ {/* Sensitivity Warning Card */}
+ {showWarning && (
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.advanced.warningTitle' })}
+
+
+ {formatMessage({ id: 'codexlens.advanced.warningMessage' })}
+
+
+
+
+ )}
+
+ {/* Current Variables Summary */}
+ {(currentEnvVars.length > 0 || settingsVars.length > 0) && (
+
+
+ {formatMessage({ id: 'codexlens.advanced.currentVars' })}
+
+
+ {settingsVars.length > 0 && (
+
+
+ {formatMessage({ id: 'codexlens.advanced.settingsVars' })}
+
+
+ {settingsVars.map(({ key }) => (
+
+ {key}
+
+ ))}
+
+
+ )}
+ {currentEnvVars.length > 0 && (
+
+
+ {formatMessage({ id: 'codexlens.advanced.customVars' })}
+
+
+ {currentEnvVars.map(({ key }) => (
+
+ {key}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* Environment Variables Editor */}
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.advanced.envEditor' })}
+
+
+
+ {formatMessage({ id: 'codexlens.advanced.envFile' })}: .env
+
+
+
+
+ {/* Env Textarea */}
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+ {/* Help Card */}
+
+
+ {formatMessage({ id: 'codexlens.advanced.helpTitle' })}
+
+
+ - โข {formatMessage({ id: 'codexlens.advanced.helpComment' })}
+ - โข {formatMessage({ id: 'codexlens.advanced.helpFormat' })}
+ - โข {formatMessage({ id: 'codexlens.advanced.helpQuotes' })}
+ - โข {formatMessage({ id: 'codexlens.advanced.helpRestart' })}
+
+
+
+ );
+}
+
+export default AdvancedTab;
diff --git a/ccw/frontend/src/components/codexlens/GpuSelector.tsx b/ccw/frontend/src/components/codexlens/GpuSelector.tsx
new file mode 100644
index 00000000..53e42bfa
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/GpuSelector.tsx
@@ -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 (
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.gpu.status' })}:
+
+ {supported !== false ? (
+ selectedDeviceId !== undefined ? (
+
+ {formatMessage({ id: 'codexlens.gpu.enabled' })}
+
+ ) : (
+
+ {formatMessage({ id: 'codexlens.gpu.available' })}
+
+ )
+ ) : (
+
+ {formatMessage({ id: 'codexlens.gpu.unavailable' })}
+
+ )}
+
+
+
+ {selectedDeviceId !== undefined && (
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header Card */}
+
+
+
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.gpu.title' })}
+
+
+ {supported !== false
+ ? formatMessage({ id: 'codexlens.gpu.supported' })
+ : formatMessage({ id: 'codexlens.gpu.notSupported' })
+ }
+
+
+
+
+
+ {selectedDeviceId !== undefined && (
+
+ )}
+
+
+
+
+ {/* Device List */}
+ {devices && devices.length > 0 ? (
+
+ {devices.map((device) => {
+ const deviceId = device.device_id ?? device.index;
+ return (
+ handleSelect(deviceId)}
+ isSelecting={isSelecting}
+ />
+ );
+ })}
+
+ ) : (
+
+
+
+ {supported !== false
+ ? formatMessage({ id: 'codexlens.gpu.noDevices' })
+ : formatMessage({ id: 'codexlens.gpu.notAvailable' })
+ }
+
+
+ )}
+
+ );
+}
+
+interface DeviceCardProps {
+ device: CodexLensGpuDevice;
+ isSelected: boolean;
+ onSelect: () => void;
+ isSelecting: boolean;
+}
+
+function DeviceCard({ device, isSelected, onSelect, isSelecting }: DeviceCardProps) {
+ const { formatMessage } = useIntl();
+
+ return (
+
+
+
+
+
+ {device.name || formatMessage({ id: 'codexlens.gpu.unknownDevice' })}
+
+ {isSelected && (
+
+
+ {formatMessage({ id: 'codexlens.gpu.selected' })}
+
+ )}
+
+
+ {formatMessage({ id: 'codexlens.gpu.type' })}: {device.type === 'discrete' ? '็ฌ็ซๆพๅก' : '้ๆๆพๅก'}
+
+ {device.memory?.total && (
+
+ {formatMessage({ id: 'codexlens.gpu.memory' })}: {(device.memory.total / 1024).toFixed(1)} GB
+
+ )}
+
+
+
+
+ );
+}
+
+export default GpuSelector;
diff --git a/ccw/frontend/src/components/codexlens/ModelCard.tsx b/ccw/frontend/src/components/codexlens/ModelCard.tsx
new file mode 100644
index 00000000..f405f956
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/ModelCard.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
+
+ {model.installed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {model.name}
+
+
+ {model.type}
+
+
+ {model.installed
+ ? formatMessage({ id: 'codexlens.models.status.downloaded' })
+ : formatMessage({ id: 'codexlens.models.status.available' })
+ }
+
+
+
+ Backend: {model.backend}
+ Size: {formatSize(model.size)}
+
+ {model.cache_path && (
+
+ {model.cache_path}
+
+ )}
+
+
+
+ {/* Action Buttons */}
+
+ {isDownloading ? (
+
+ ) : model.installed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Download Progress */}
+ {isDownloading && (
+
+
+
+ {formatMessage({ id: 'codexlens.models.downloading' })}
+
+ {downloadProgress}%
+
+
+
+ )}
+
+
+ );
+}
+
+// ========== 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 (
+
+
+
+ {formatMessage({ id: 'codexlens.models.custom.title' })}
+
+
+
+ );
+}
+
+export default ModelCard;
diff --git a/ccw/frontend/src/components/codexlens/ModelsTab.test.tsx b/ccw/frontend/src/components/codexlens/ModelsTab.test.tsx
new file mode 100644
index 00000000..1912e0bb
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/ModelsTab.test.tsx
@@ -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();
+ 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();
+
+ expect(screen.getByPlaceholderText(/Search models/i)).toBeInTheDocument();
+ });
+
+ it('should render filter buttons with counts', () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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(, { 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(, { 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(, { 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();
+
+ expect(screen.getByText(/Custom Model/i)).toBeInTheDocument();
+ });
+
+ it('should translate custom model section in Chinese', () => {
+ render(, { 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();
+
+ // Component should still render despite error
+ expect(screen.getByText(/BAAI\/bge-small-en-v1.5/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/ccw/frontend/src/components/codexlens/ModelsTab.tsx b/ccw/frontend/src/components/codexlens/ModelsTab.tsx
new file mode 100644
index 00000000..bfa5ee12
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/ModelsTab.tsx
@@ -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('all');
+ const [downloadingProfile, setDownloadingProfile] = useState(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 (
+
+
+
+ {formatMessage({ id: 'codexlens.models.notInstalled.title' })}
+
+
+ {formatMessage({ id: 'codexlens.models.notInstalled.description' })}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with Search and Actions */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+
+
+
+ {/* Stats and Filters */}
+
+
+
+
+ {formatMessage({ id: 'codexlens.models.filters.label' })}
+
+
+
+ {filterButtons.map(({ type, label, count }) => (
+
+ ))}
+
+
+
+ {/* Custom Model Input */}
+
+
+ {/* Model List */}
+ {isLoading ? (
+
+ {formatMessage({ id: 'common.actions.loading' })}
+
+ ) : filteredModels.length === 0 ? (
+
+
+
+ {formatMessage({ id: 'codexlens.models.empty.title' })}
+
+
+ {formatMessage({ id: 'codexlens.models.empty.description' })}
+
+
+ ) : (
+
+ {filteredModels.map((model) => (
+ {
+ setDownloadingProfile(null);
+ setDownloadProgress(0);
+ }}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+export default ModelsTab;
diff --git a/ccw/frontend/src/components/codexlens/OverviewTab.test.tsx b/ccw/frontend/src/components/codexlens/OverviewTab.test.tsx
new file mode 100644
index 00000000..9a802514
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/OverviewTab.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText(/Index Count/i)).toBeInTheDocument();
+ expect(screen.getByText('100')).toBeInTheDocument();
+ });
+
+ it('should render quick actions section', () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText(/Not Ready/i)).toBeInTheDocument();
+ });
+
+ it('should disable action buttons when not ready', () => {
+ render();
+
+ 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();
+
+ 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(
+
+ );
+
+ // 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(, { 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(, { 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(
+
+ );
+
+ // 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(
+
+ );
+
+ // 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(
+
+ );
+
+ // Should not crash and render available data
+ expect(screen.getByText(/Version/i)).toBeInTheDocument();
+ });
+
+ it('should handle missing config gracefully', () => {
+ render(
+
+ );
+
+ // 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(
+
+ );
+
+ expect(screen.getByText(/Index Path/i)).toBeInTheDocument();
+ });
+
+ it('should handle unknown version', () => {
+ const unknownVersionStatus: CodexLensVenvStatus = {
+ ...mockStatus,
+ version: '',
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText(/Version/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/ccw/frontend/src/components/codexlens/OverviewTab.tsx b/ccw/frontend/src/components/codexlens/OverviewTab.tsx
new file mode 100644
index 00000000..36b20a7c
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/OverviewTab.tsx
@@ -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 (
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (!installed) {
+ return (
+
+
+
+ {formatMessage({ id: 'codexlens.overview.notInstalled.title' })}
+
+
+ {formatMessage({ id: 'codexlens.overview.notInstalled.message' })}
+
+
+ );
+ }
+
+ const isReady = status?.ready ?? false;
+ const version = status?.version ?? 'Unknown';
+ const indexDir = config?.index_dir ?? '~/.codexlens/indexes';
+ const indexCount = config?.index_count ?? 0;
+
+ return (
+
+ {/* Status Cards */}
+
+ {/* Installation Status */}
+
+
+
+ {isReady ? (
+
+ ) : (
+
+ )}
+
+
+
+ {formatMessage({ id: 'codexlens.overview.status.installation' })}
+
+
+ {isReady
+ ? formatMessage({ id: 'codexlens.overview.status.ready' })
+ : formatMessage({ id: 'codexlens.overview.status.notReady' })
+ }
+
+
+
+
+
+ {/* Version */}
+
+
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.overview.status.version' })}
+
+
{version}
+
+
+
+
+ {/* Index Path */}
+
+
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.overview.status.indexPath' })}
+
+
+ {indexDir}
+
+
+
+
+
+ {/* Index Count */}
+
+
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.overview.status.indexCount' })}
+
+
{indexCount}
+
+
+
+
+
+ {/* Quick Actions */}
+
+
+
+ {formatMessage({ id: 'codexlens.overview.actions.title' })}
+
+
+
+
+ }
+ label={formatMessage({ id: 'codexlens.overview.actions.ftsFull' })}
+ description={formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' })}
+ disabled={!isReady}
+ />
+ }
+ label={formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' })}
+ description={formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' })}
+ disabled={!isReady}
+ />
+ }
+ label={formatMessage({ id: 'codexlens.overview.actions.vectorFull' })}
+ description={formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' })}
+ disabled={!isReady}
+ />
+ }
+ label={formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' })}
+ description={formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' })}
+ disabled={!isReady}
+ />
+
+
+
+
+ {/* Venv Details */}
+ {status && (
+
+
+
+ {formatMessage({ id: 'codexlens.overview.venv.title' })}
+
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.overview.venv.pythonVersion' })}
+
+ {status.pythonVersion || 'Unknown'}
+
+
+
+ {formatMessage({ id: 'codexlens.overview.venv.venvPath' })}
+
+
+ {status.venvPath || 'Unknown'}
+
+
+
+
+
+ )}
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/ccw/frontend/src/components/codexlens/SettingsTab.test.tsx b/ccw/frontend/src/components/codexlens/SettingsTab.test.tsx
new file mode 100644
index 00000000..31b1d471
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/SettingsTab.test.tsx
@@ -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();
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ 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();
+
+ 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(, { 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(, { 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();
+
+ 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)
+ );
+ });
+ });
+ });
+});
diff --git a/ccw/frontend/src/components/codexlens/SettingsTab.tsx b/ccw/frontend/src/components/codexlens/SettingsTab.tsx
new file mode 100644
index 00000000..3769ec63
--- /dev/null
+++ b/ccw/frontend/src/components/codexlens/SettingsTab.tsx
@@ -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({});
+ 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 (
+
+ {/* Current Info Card */}
+
+
+
+
{formatMessage({ id: 'codexlens.settings.currentCount' })}
+
{indexCount}
+
+
+
{formatMessage({ id: 'codexlens.settings.currentWorkers' })}
+
{apiMaxWorkers}
+
+
+
{formatMessage({ id: 'codexlens.settings.currentBatchSize' })}
+
{apiBatchSize}
+
+
+
+
+ {/* Configuration Form */}
+
+
+ {formatMessage({ id: 'codexlens.settings.configTitle' })}
+
+
+
+ {/* Index Directory */}
+
+
+
handleFieldChange('index_dir', e.target.value)}
+ placeholder={formatMessage({ id: 'codexlens.settings.indexDir.placeholder' })}
+ error={!!errors.index_dir}
+ disabled={isLoading}
+ />
+ {errors.index_dir && (
+
{errors.index_dir}
+ )}
+
+ {formatMessage({ id: 'codexlens.settings.indexDir.hint' })}
+
+
+
+ {/* API Max Workers */}
+
+
+
handleFieldChange('api_max_workers', parseInt(e.target.value) || 1)}
+ error={!!errors.api_max_workers}
+ disabled={isLoading}
+ />
+ {errors.api_max_workers && (
+
{errors.api_max_workers}
+ )}
+
+ {formatMessage({ id: 'codexlens.settings.maxWorkers.hint' })}
+
+
+
+ {/* API Batch Size */}
+
+
+
handleFieldChange('api_batch_size', parseInt(e.target.value) || 1)}
+ error={!!errors.api_batch_size}
+ disabled={isLoading}
+ />
+ {errors.api_batch_size && (
+
{errors.api_batch_size}
+ )}
+
+ {formatMessage({ id: 'codexlens.settings.batchSize.hint' })}
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ );
+}
+
+export default SettingsTab;
diff --git a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx
index 17260f7e..fa485678 100644
--- a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx
+++ b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx
@@ -56,6 +56,19 @@ export interface HookQuickTemplatesProps {
* Predefined hook templates for quick installation
*/
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',
name: 'CCW Dashboard Notify',
diff --git a/ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx b/ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx
index 7242f1cf..fd0ea1fe 100644
--- a/ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx
+++ b/ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx
@@ -5,13 +5,15 @@
import { useState } from 'react';
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 { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
+import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import type { DiscoverySession, Finding } from '@/lib/api';
+import type { Issue } from '@/lib/api';
import type { FindingFilters } from '@/hooks/useIssues';
import { FindingList } from './FindingList';
@@ -22,6 +24,9 @@ interface DiscoveryDetailProps {
filters: FindingFilters;
onFilterChange: (filters: FindingFilters) => 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({
@@ -31,9 +36,35 @@ export function DiscoveryDetail({
filters,
onFilterChange,
onExport,
+ onExportSelected,
+ isExporting = false,
+ issues = [],
}: DiscoveryDetailProps) {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState('findings');
+ const [selectedIssue, setSelectedIssue] = useState(null);
+ const [selectedIds, setSelectedIds] = useState([]);
+
+ 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) {
return (
@@ -73,10 +104,25 @@ export function DiscoveryDetail({
{formatMessage({ id: 'issues.discovery.sessionId' })}: {session.id}
-
+
+ {selectedIds.length > 0 && onExportSelected && (
+
+ )}
+
+
{/* Status Badge */}
@@ -125,7 +171,14 @@ export function DiscoveryDetail({
-
+
@@ -219,6 +272,15 @@ export function DiscoveryDetail({
+
+ {/* Issue Detail Drawer */}
+
);
}
+
+export default DiscoveryDetail;
diff --git a/ccw/frontend/src/components/issue/discovery/FindingList.tsx b/ccw/frontend/src/components/issue/discovery/FindingList.tsx
index 823c78d3..9393d183 100644
--- a/ccw/frontend/src/components/issue/discovery/FindingList.tsx
+++ b/ccw/frontend/src/components/issue/discovery/FindingList.tsx
@@ -3,12 +3,14 @@
// ========================================
// Displays findings with filters and severity badges
+import { useState } from 'react';
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 { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
+import { cn } from '@/lib/utils';
import type { Finding } from '@/lib/api';
import type { FindingFilters } from '@/hooks/useIssues';
@@ -16,17 +18,64 @@ interface FindingListProps {
findings: Finding[];
filters: FindingFilters;
onFilterChange: (filters: FindingFilters) => void;
+ onFindingClick?: (finding: Finding) => void;
+ selectedIds?: string[];
+ onSelectionChange?: (selectedIds: string[]) => void;
}
-const severityConfig = {
- critical: { variant: 'destructive' as const, label: 'issues.discovery.severity.critical' },
- high: { variant: 'destructive' as const, label: 'issues.discovery.severity.high' },
- medium: { variant: 'warning' as const, label: 'issues.discovery.severity.medium' },
- low: { variant: 'secondary' as const, label: 'issues.discovery.severity.low' },
+const severityConfig: Record = {
+ critical: { variant: 'destructive', label: 'issues.discovery.severity.critical' },
+ high: { variant: 'destructive', label: 'issues.discovery.severity.high' },
+ medium: { variant: 'warning', label: 'issues.discovery.severity.medium' },
+ 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 [internalSelection, setInternalSelection] = useState>(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() : 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
const uniqueTypes = Array.from(new Set(findings.map(f => f.type))).sort();
@@ -36,10 +85,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
- {formatMessage({ id: 'issues.discovery.noFindings' })}
+ {formatMessage({ id: 'issues.discovery.findings.noFindings' })}
- {formatMessage({ id: 'issues.discovery.noFindingsDescription' })}
+ {formatMessage({ id: 'issues.discovery.findings.noFindingsDescription' })}
);
@@ -52,7 +101,7 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
onFilterChange({ ...filters, search: e.target.value || undefined })}
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'] })}
>
-
+
- {formatMessage({ id: 'issues.discovery.allSeverities' })}
+ {formatMessage({ id: 'issues.discovery.findings.severity.all' })}
{formatMessage({ id: 'issues.discovery.severity.critical' })}
{formatMessage({ id: 'issues.discovery.severity.high' })}
{formatMessage({ id: 'issues.discovery.severity.medium' })}
@@ -79,50 +128,155 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
onValueChange={(v) => onFilterChange({ ...filters, type: v === 'all' ? undefined : v })}
>
-
+
- {formatMessage({ id: 'issues.discovery.allTypes' })}
+ {formatMessage({ id: 'issues.discovery.findings.type.all' })}
{uniqueTypes.map(type => (
{type}
))}
)}
+
+
+ {/* Select All */}
+ {onSelectionChange && (
+
+ )}
+
{/* Findings List */}
{findings.map((finding) => {
- const config = severityConfig[finding.severity];
+ const config = getSeverityConfig(finding.severity);
+ const isSelected = selectionSet.has(finding.id);
return (
-
-
-
-
- {formatMessage({ id: config.label })}
-
- {finding.type && (
-
- {finding.type}
-
- )}
-
- {finding.file && (
-
-
-
{finding.file}
- {finding.line &&
:{finding.line}}
+
{
+ // Don't trigger finding click when clicking checkbox
+ if ((e.target as HTMLElement).closest('.selection-checkbox')) return;
+ onFindingClick?.(finding);
+ }}
+ >
+
+ {/* Checkbox */}
+ {onSelectionChange && (
+
{
+ e.stopPropagation();
+ handleToggleSelection(finding.id);
+ }}
+ >
+
+ {isSelected && }
+
)}
+
+
+
+
+ {formatMessage({ id: config.label })}
+
+ {finding.type && (
+
+ {finding.type}
+
+ )}
+ {finding.exported && (
+
+
+ {formatMessage({ id: 'issues.discovery.findings.exported' })}
+
+ )}
+ {finding.issue_id && (
+
+ {formatMessage({ id: 'issues.discovery.findings.hasIssue' })}: {finding.issue_id}
+
+ )}
+
+ {finding.file && (
+
+
+ {finding.file}
+ {finding.line && :{finding.line}}
+
+ )}
+
+
{finding.title}
+
{finding.description}
+ {finding.code_snippet && (
+
+ {finding.code_snippet}
+
+ )}
+
- {finding.title}
- {finding.description}
- {finding.code_snippet && (
-
- {finding.code_snippet}
-
- )}
);
})}
@@ -130,8 +284,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
{/* Count */}
- {formatMessage({ id: 'issues.discovery.showingCount' }, { count: findings.length })}
+ {formatMessage({ id: 'issues.discovery.findings.showingCount' }, { count: findings.length })}
);
}
+
+export default FindingList;
diff --git a/ccw/frontend/src/components/issue/hub/DiscoveryPanel.tsx b/ccw/frontend/src/components/issue/hub/DiscoveryPanel.tsx
index f99d5faf..7a8a118a 100644
--- a/ccw/frontend/src/components/issue/hub/DiscoveryPanel.tsx
+++ b/ccw/frontend/src/components/issue/hub/DiscoveryPanel.tsx
@@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
import { Radar, AlertCircle, Loader2 } from 'lucide-react';
import { Card } from '@/components/ui/Card';
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 { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail';
@@ -27,8 +27,16 @@ export function DiscoveryPanel() {
setFilters,
selectSession,
exportFindings,
+ exportSelectedFindings,
+ isExporting,
} = 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) {
return (
@@ -144,6 +152,9 @@ export function DiscoveryPanel() {
filters={filters}
onFilterChange={setFilters}
onExport={exportFindings}
+ onExportSelected={exportSelectedFindings}
+ isExporting={isExporting}
+ issues={issues}
/>
)}
diff --git a/ccw/frontend/src/components/issue/hub/IssueDrawer.tsx b/ccw/frontend/src/components/issue/hub/IssueDrawer.tsx
new file mode 100644
index 00000000..a8f8d383
--- /dev/null
+++ b/ccw/frontend/src/components/issue/hub/IssueDrawer.tsx
@@ -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 }> = {
+ 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 = {
+ 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('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 */}
+
+
+ {/* Drawer */}
+
+ {/* Header */}
+
+
+
+ {issue.id}
+
+
+ {formatMessage({ id: status.label })}
+
+
+ {formatMessage({ id: priority.label })}
+
+
+
+ {issue.title}
+
+
+
+
+
+ {/* Tabs */}
+
+
setActiveTab(v as TabValue)} className="w-full">
+
+
+
+ {formatMessage({ id: 'issues.detail.tabs.overview' })}
+
+
+
+ {formatMessage({ id: 'issues.detail.tabs.solutions' })}
+ {issue.solutions && issue.solutions.length > 0 && (
+
+ {issue.solutions.length}
+
+ )}
+
+
+
+ {formatMessage({ id: 'issues.detail.tabs.history' })}
+
+
+
+ JSON
+
+
+
+ {/* Tab Content */}
+
+ {/* Overview Tab */}
+
+
+ {/* Context */}
+ {issue.context && (
+
+
+ {formatMessage({ id: 'issues.detail.overview.context' })}
+
+
+ {issue.context}
+
+
+ )}
+
+ {/* Labels */}
+ {issue.labels && issue.labels.length > 0 && (
+
+
+ {formatMessage({ id: 'issues.detail.overview.labels' })}
+
+
+ {issue.labels.map((label, index) => (
+
+
+ {label}
+
+ ))}
+
+
+ )}
+
+ {/* Meta Info */}
+
+
+
{formatMessage({ id: 'issues.detail.overview.createdAt' })}
+
{new Date(issue.createdAt).toLocaleString()}
+
+ {issue.updatedAt && (
+
+
{formatMessage({ id: 'issues.detail.overview.updatedAt' })}
+
{new Date(issue.updatedAt).toLocaleString()}
+
+ )}
+
+
+
+
+ {/* Solutions Tab */}
+
+ {!issue.solutions || issue.solutions.length === 0 ? (
+
+
+
{formatMessage({ id: 'issues.detail.solutions.empty' })}
+
+ ) : (
+
+ {issue.solutions.map((solution, index) => (
+
+
+
+ {solution.status}
+
+ {solution.estimatedEffort && (
+
+ {solution.estimatedEffort}
+
+ )}
+
+
{solution.description}
+ {solution.approach && (
+
{solution.approach}
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* History Tab */}
+
+
+
+
{formatMessage({ id: 'issues.detail.history.empty' })}
+
+
+
+ {/* JSON Tab */}
+
+
+ {JSON.stringify(issue, null, 2)}
+
+
+
+
+
+
+ >
+ );
+}
+
+export default IssueDrawer;
diff --git a/ccw/frontend/src/components/issue/hub/IssuesPanel.tsx b/ccw/frontend/src/components/issue/hub/IssuesPanel.tsx
index 01545b8f..f98711d6 100644
--- a/ccw/frontend/src/components/issue/hub/IssuesPanel.tsx
+++ b/ccw/frontend/src/components/issue/hub/IssuesPanel.tsx
@@ -6,115 +6,27 @@
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
- Plus,
Search,
- RefreshCw,
- Loader2,
- Github,
CheckCircle,
Clock,
AlertTriangle,
AlertCircle,
} 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 { 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 { IssueCard } from '@/components/shared/IssueCard';
+import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import { useIssues, useIssueMutations } from '@/hooks';
import type { Issue } from '@/lib/api';
-import { cn } from '@/lib/utils';
type StatusFilter = 'all' | Issue['status'];
type PriorityFilter = 'all' | Issue['priority'];
-interface NewIssueDialogProps {
- open: boolean;
- 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('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 (
-
- );
+interface IssuesPanelProps {
+ onCreateIssue?: () => void;
}
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 [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
- const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
+ const [selectedIssue, setSelectedIssue] = useState(null);
- const { issues, issuesByStatus, openCount, criticalCount, isLoading, isFetching, refetch } = useIssues({
+ const { issues, issuesByStatus, openCount, criticalCount, isLoading } = useIssues({
filter: {
search: searchQuery || 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(() => ({
all: issues.length,
@@ -191,11 +103,6 @@ export function IssuesPanel() {
completed: issuesByStatus.completed?.length || 0,
}), [issues, issuesByStatus]);
- const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
- await createIssue(data);
- setIsNewIssueOpen(false);
- };
-
const handleEditIssue = (_issue: Issue) => {};
const handleDeleteIssue = async (issue: Issue) => {
@@ -208,23 +115,16 @@ export function IssuesPanel() {
await updateIssue(issue.id, { status });
};
+ const handleIssueClick = (issue: Issue) => {
+ setSelectedIssue(issue);
+ };
+
+ const handleCloseDrawer = () => {
+ setSelectedIssue(null);
+ };
+
return (
-
-
-
-
-
-
@@ -312,9 +212,21 @@ export function IssuesPanel() {
- {}} onIssueEdit={handleEditIssue} onIssueDelete={handleDeleteIssue} onStatusChange={handleStatusChange} />
+
-
+ {/* Issue Detail Drawer */}
+
);
}
diff --git a/ccw/frontend/src/components/issue/hub/QueuePanel.tsx b/ccw/frontend/src/components/issue/hub/QueuePanel.tsx
index 47f98797..9dcaa557 100644
--- a/ccw/frontend/src/components/issue/hub/QueuePanel.tsx
+++ b/ccw/frontend/src/components/issue/hub/QueuePanel.tsx
@@ -3,9 +3,9 @@
// ========================================
// Content panel for Queue tab in IssueHub
+import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
- RefreshCw,
AlertCircle,
CheckCircle,
Clock,
@@ -13,11 +13,11 @@ import {
GitMerge,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
-import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { QueueCard } from '@/components/issue/queue/QueueCard';
+import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
import { useIssueQueue, useQueueMutations } from '@/hooks';
-import { cn } from '@/lib/utils';
+import type { QueueItem } from '@/lib/api';
// ========== Loading Skeleton ==========
@@ -70,17 +70,20 @@ function QueueEmptyState() {
export function QueuePanel() {
const { formatMessage } = useIntl();
+ const [selectedItem, setSelectedItem] = useState
(null);
- const { data: queueData, isLoading, isFetching, refetch, error } = useIssueQueue();
+ const { data: queueData, isLoading, error } = useIssueQueue();
const {
activateQueue,
deactivateQueue,
deleteQueue,
mergeQueues,
+ splitQueue,
isActivating,
isDeactivating,
isDeleting,
isMerging,
+ isSplitting,
} = useQueueMutations();
// 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) {
return ;
}
@@ -150,18 +169,6 @@ export function QueuePanel() {
return (
- {/* Header Actions */}
-
-
-
-
{/* Stats Cards */}
@@ -229,13 +236,23 @@ export function QueuePanel() {
onDeactivate={handleDeactivate}
onDelete={handleDelete}
onMerge={handleMerge}
+ onSplit={handleSplit}
+ onItemClick={handleItemClick}
isActivating={isActivating}
isDeactivating={isDeactivating}
isDeleting={isDeleting}
isMerging={isMerging}
+ isSplitting={isSplitting}
/>
+ {/* Solution Detail Drawer */}
+
+
{/* Status Footer */}
diff --git a/ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx b/ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx
index ff3579f2..d453ee41 100644
--- a/ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx
+++ b/ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx
@@ -9,20 +9,22 @@ import { ChevronDown, ChevronRight, GitMerge, ArrowRight } from 'lucide-react';
import { Card, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
+import type { QueueItem } from '@/lib/api';
// ========== Types ==========
export interface ExecutionGroupProps {
group: string;
- items: string[];
+ items: QueueItem[];
type?: 'parallel' | 'sequential';
+ onItemClick?: (item: QueueItem) => void;
}
// ========== Component ==========
-export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionGroupProps) {
+export function ExecutionGroup({ group, items, type = 'sequential', onItemClick }: ExecutionGroupProps) {
const { formatMessage } = useIntl();
- const [isExpanded, setIsExpanded] = useState(true);
+ const [isExpanded, setIsExpanded] = useState(false);
const isParallel = type === 'parallel';
return (
@@ -56,7 +58,7 @@ export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionG
- {items.length} {items.length === 1 ? 'item' : 'items'}
+ {formatMessage({ id: 'issues.queue.itemCount' }, { count: items.length })}
@@ -67,22 +69,38 @@ export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionG
"space-y-1 mt-2",
isParallel ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "space-y-1"
)}>
- {items.map((item, index) => (
-
-
- {isParallel ? '' : `${index + 1}.`}
-
-
- {item}
-
-
- ))}
+ {items.map((item, index) => {
+ // Parse item_id to extract type and ID
+ const [itemType, ...idParts] = item.item_id.split('-');
+ const displayId = idParts.join('-');
+ const typeLabel = itemType === 'issue' ? formatMessage({ id: 'issues.solution.shortIssue' })
+ : itemType === 'solution' ? formatMessage({ id: 'issues.solution.shortSolution' })
+ : itemType;
+
+ return (
+
onItemClick?.(item)}
+ className={cn(
+ "flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm",
+ "hover:bg-muted transition-colors cursor-pointer"
+ )}
+ >
+
+ {isParallel ? '' : `${index + 1}.`}
+
+
+ {typeLabel}
+
+
+ {displayId}
+
+
+ {formatMessage({ id: `issues.queue.status.${item.status}` })}
+
+
+ );
+ })}
)}
diff --git a/ccw/frontend/src/components/issue/queue/QueueActions.tsx b/ccw/frontend/src/components/issue/queue/QueueActions.tsx
index a73d8666..9b972bf9 100644
--- a/ccw/frontend/src/components/issue/queue/QueueActions.tsx
+++ b/ccw/frontend/src/components/issue/queue/QueueActions.tsx
@@ -1,18 +1,11 @@
// ========================================
// 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 { useIntl } from 'react-intl';
-import { Play, Pause, Trash2, Merge, Loader2 } from 'lucide-react';
-import {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
-} from '@/components/ui/Dropdown';
+import { Play, Pause, Trash2, Merge, GitBranch, Loader2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
@@ -32,7 +25,9 @@ import {
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
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 ==========
@@ -43,10 +38,12 @@ export interface QueueActionsProps {
onDeactivate?: () => void;
onDelete?: (queueId: string) => void;
onMerge?: (sourceId: string, targetId: string) => void;
+ onSplit?: (sourceQueueId: string, itemIds: string[]) => void;
isActivating?: boolean;
isDeactivating?: boolean;
isDeleting?: boolean;
isMerging?: boolean;
+ isSplitting?: boolean;
}
// ========== Component ==========
@@ -58,18 +55,25 @@ export function QueueActions({
onDeactivate,
onDelete,
onMerge,
+ onSplit,
isActivating = false,
isDeactivating = false,
isDeleting = false,
isMerging = false,
+ isSplitting = false,
}: QueueActionsProps) {
const { formatMessage } = useIntl();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isMergeOpen, setIsMergeOpen] = useState(false);
+ const [isSplitOpen, setIsSplitOpen] = useState(false);
const [mergeTargetId, setMergeTargetId] = useState('');
+ const [selectedItemIds, setSelectedItemIds] = useState([]);
// 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 = () => {
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 (
<>
-
-
-
-
-
- {!isActive && onActivate && (
- onActivate(queueId)} disabled={isActivating}>
- {isActivating ? (
-
- ) : (
-
- )}
- {formatMessage({ id: 'issues.queue.actions.activate' })}
-
- )}
- {isActive && onDeactivate && (
- onDeactivate()} disabled={isDeactivating}>
- {isDeactivating ? (
-
- ) : (
-
- )}
- {formatMessage({ id: 'issues.queue.actions.deactivate' })}
-
- )}
- setIsMergeOpen(true)} disabled={isMerging}>
- {isMerging ? (
-
- ) : (
-
- )}
- {formatMessage({ id: 'issues.queue.actions.merge' })}
-
-
- setIsDeleteOpen(true)}
- disabled={isDeleting}
- className="text-destructive"
+ {/* Direct action buttons */}
+
+ {/* Activate/Deactivate button */}
+ {!isActive && onActivate && (
+
+ )}
+ {isActive && onDeactivate && (
+
+ )}
+
+ {/* Merge button */}
+
+
+ {/* Split button - only show if more than 1 item */}
+ {canSplit && (
+
+ )}
+
+ {/* Delete button */}
+
+
{/* Delete Confirmation Dialog */}
@@ -227,6 +285,100 @@ export function QueueActions({
+
+ {/* Split Dialog */}
+
>
);
}
diff --git a/ccw/frontend/src/components/issue/queue/QueueCard.tsx b/ccw/frontend/src/components/issue/queue/QueueCard.tsx
index 6693d1f2..fca4c0b4 100644
--- a/ccw/frontend/src/components/issue/queue/QueueCard.tsx
+++ b/ccw/frontend/src/components/issue/queue/QueueCard.tsx
@@ -21,10 +21,13 @@ export interface QueueCardProps {
onDeactivate?: () => void;
onDelete?: (queueId: string) => void;
onMerge?: (sourceId: string, targetId: string) => void;
+ onSplit?: (sourceQueueId: string, itemIds: string[]) => void;
+ onItemClick?: (item: import('@/lib/api').QueueItem) => void;
isActivating?: boolean;
isDeactivating?: boolean;
isDeleting?: boolean;
isMerging?: boolean;
+ isSplitting?: boolean;
className?: string;
}
@@ -37,10 +40,13 @@ export function QueueCard({
onDeactivate,
onDelete,
onMerge,
+ onSplit,
+ onItemClick,
isActivating = false,
isDeactivating = false,
isDeleting = false,
isMerging = false,
+ isSplitting = false,
className,
}: QueueCardProps) {
const { formatMessage } = useIntl();
@@ -101,10 +107,12 @@ export function QueueCard({
onDeactivate={onDeactivate}
onDelete={onDelete}
onMerge={onMerge}
+ onSplit={onSplit}
isActivating={isActivating}
isDeactivating={isDeactivating}
isDeleting={isDeleting}
isMerging={isMerging}
+ isSplitting={isSplitting}
/>
@@ -143,6 +151,7 @@ export function QueueCard({
group={group.id}
items={group.items}
type={group.type}
+ onItemClick={onItemClick}
/>
))}
diff --git a/ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx b/ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx
new file mode 100644
index 00000000..3ecfb03e
--- /dev/null
+++ b/ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx
@@ -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 }> = {
+ 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('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 */}
+
+
+ {/* Drawer */}
+
+ {/* Header */}
+
+
+
+ {item.item_id}
+
+
+ {formatMessage({ id: status.label })}
+
+
+
+
+ {formatMessage({ id: 'solution.issue' })}: {issueId}
+
+
+ {formatMessage({ id: 'solution.solution' })}: {solutionId}
+
+
+
+
+
+
+ {/* Tabs */}
+
+
setActiveTab(v as TabValue)} className="w-full">
+
+
+
+ {formatMessage({ id: 'solution.tabs.overview' })}
+
+
+
+ {formatMessage({ id: 'solution.tabs.tasks' })}
+
+
+
+ {formatMessage({ id: 'solution.tabs.json' })}
+
+
+
+ {/* Tab Content */}
+
+ {/* Overview Tab */}
+
+
+ {/* Execution Info */}
+
+
+ {formatMessage({ id: 'solution.overview.executionInfo' })}
+
+
+
+
{formatMessage({ id: 'solution.overview.executionOrder' })}
+
{item.execution_order}
+
+
+
{formatMessage({ id: 'solution.overview.semanticPriority' })}
+
{item.semantic_priority}
+
+
+
{formatMessage({ id: 'solution.overview.group' })}
+
{item.execution_group}
+
+
+
{formatMessage({ id: 'solution.overview.taskCount' })}
+
{item.task_count || '-'}
+
+
+
+
+ {/* Dependencies */}
+ {item.depends_on && item.depends_on.length > 0 && (
+
+
+ {formatMessage({ id: 'solution.overview.dependencies' })}
+
+
+ {item.depends_on.map((dep, index) => (
+
+ {dep}
+
+ ))}
+
+
+ )}
+
+ {/* Files Touched */}
+ {item.files_touched && item.files_touched.length > 0 && (
+
+
+ {formatMessage({ id: 'solution.overview.filesTouched' })}
+
+
+ {item.files_touched.map((file, index) => (
+
+ {file}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Tasks Tab */}
+
+
+
+
{formatMessage({ id: 'solution.tasks.comingSoon' })}
+
+
+
+ {/* JSON Tab */}
+
+
+ {JSON.stringify(item, null, 2)}
+
+
+
+
+
+
+ >
+ );
+}
+
+export default SolutionDrawer;
diff --git a/ccw/frontend/src/components/layout/Sidebar.tsx b/ccw/frontend/src/components/layout/Sidebar.tsx
index e376f8ab..509ab1f5 100644
--- a/ccw/frontend/src/components/layout/Sidebar.tsx
+++ b/ccw/frontend/src/components/layout/Sidebar.tsx
@@ -60,8 +60,6 @@ const navItemDefinitions: Omit[] = [
{ path: '/orchestrator', icon: Workflow },
{ path: '/loops', icon: RefreshCw },
{ path: '/issues', icon: AlertCircle },
- { path: '/issues?tab=queue', icon: ListTodo },
- { path: '/issues?tab=discovery', icon: Search },
{ path: '/skills', icon: Sparkles },
{ path: '/commands', icon: Terminal },
{ path: '/memory', icon: Brain },
@@ -69,6 +67,7 @@ const navItemDefinitions: Omit[] = [
{ path: '/hooks', icon: GitFork },
{ path: '/settings', icon: Settings },
{ path: '/settings/rules', icon: Shield },
+ { path: '/settings/codexlens', icon: Sparkles },
{ path: '/help', icon: HelpCircle },
];
@@ -110,8 +109,6 @@ export function Sidebar({
'/orchestrator': 'main.orchestrator',
'/loops': 'main.loops',
'/issues': 'main.issues',
- '/issues?tab=queue': 'main.issueQueue',
- '/issues?tab=discovery': 'main.issueDiscovery',
'/skills': 'main.skills',
'/commands': 'main.commands',
'/memory': 'main.memory',
@@ -119,6 +116,7 @@ export function Sidebar({
'/hooks': 'main.hooks',
'/settings': 'main.settings',
'/settings/rules': 'main.rules',
+ '/settings/codexlens': 'main.codexlens',
'/help': 'main.help',
};
return navItemDefinitions.map((item) => ({
diff --git a/ccw/frontend/src/components/session-detail/context/AssetsCard.tsx b/ccw/frontend/src/components/session-detail/context/AssetsCard.tsx
new file mode 100644
index 00000000..d8571745
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/AssetsCard.tsx
@@ -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 (
+
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.assets.title' })}
+ {totalAssets}
+
+
+
+ 0 ? 'documentation' : sourceCount > 0 ? 'source_code' : 'tests'}>
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.categories.documentation' })}
+ {docCount > 0 && ({docCount})}
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.categories.sourceCode' })}
+ {sourceCount > 0 && ({sourceCount})}
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.categories.tests' })}
+ {testCount > 0 && ({testCount})}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface AssetGridProps {
+ items: AssetItem[];
+ type: string;
+}
+
+function AssetGrid({ items }: AssetGridProps) {
+ const { formatMessage } = useIntl();
+
+ if (items.length === 0) {
+ return (
+
+ {formatMessage({ id: 'sessionDetail.context.assets.noData' })}
+
+ );
+ }
+
+ return (
+
+ {items.map((item, index) => (
+
+
+
+ {item.relevance_score !== undefined && (
+
0.7 ? 'success' : item.relevance_score > 0.4 ? 'default' : 'secondary'}
+ className="flex-shrink-0"
+ >
+ {Math.round(item.relevance_score * 100)}%
+
+ )}
+
+
+
+ {item.scope && (
+
+ {formatMessage({ id: 'sessionDetail.context.assets.scope' })}: {item.scope}
+
+ )}
+ {item.contains && item.contains.length > 0 && (
+
+ {formatMessage({ id: 'sessionDetail.context.assets.contains' })}: {item.contains.join(', ')}
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/ccw/frontend/src/components/session-detail/context/ConflictDetectionCard.tsx b/ccw/frontend/src/components/session-detail/context/ConflictDetectionCard.tsx
new file mode 100644
index 00000000..0d95b1a9
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/ConflictDetectionCard.tsx
@@ -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 (
+
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.conflictDetection.title' })}
+
+ {formatMessage({ id: `sessionDetail.context.conflictDetection.riskLevel.${data.risk_level}` })}
+
+
+
+
+ {/* Mitigation Strategy */}
+ {data.mitigation_strategy && (
+
+
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.conflictDetection.mitigation' })}
+
+
{data.mitigation_strategy}
+
+
+
+ )}
+
+ {/* Risk Factors */}
+ {data.risk_factors && (
+
+ )}
+
+ {/* Affected Modules */}
+ {data.affected_modules && data.affected_modules.length > 0 && (
+
+
+ {formatMessage({ id: 'sessionDetail.context.conflictDetection.affectedModules' })}
+
+
+
+ )}
+
+
+ );
+}
+
+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 (
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.conflictDetection.riskFactors' })}
+
+
+ {hasTestGaps && (
+
+
+ {formatMessage({ id: 'sessionDetail.context.conflictDetection.testGaps' })}
+
+
+
+ )}
+ {hasExistingImpl && (
+
+
+ {formatMessage({ id: 'sessionDetail.context.conflictDetection.existingImplementations' })}
+
+
+
+ )}
+
+
+ );
+}
+
+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: '',
+ };
+ }
+}
diff --git a/ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx b/ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx
new file mode 100644
index 00000000..724896d8
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/DependenciesCard.tsx
@@ -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 (
+
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.dependencies.title' })}
+ {internalCount + externalCount}
+
+
+
+ {data.internal && data.internal.length > 0 && (
+
+ )}
+
+ {data.external && data.external.length > 0 && (
+
+ )}
+
+
+ );
+}
+
+interface InternalDependenciesSectionProps {
+ dependencies: InternalDependency[];
+}
+
+function InternalDependenciesSection({ dependencies }: InternalDependenciesSectionProps) {
+ const { formatMessage } = useIntl();
+
+ return (
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.dependencies.internal' })} ({dependencies.length})
+
+
+
+
+
+ |
+ {formatMessage({ id: 'sessionDetail.context.dependencies.from' })}
+ |
+
+ {formatMessage({ id: 'sessionDetail.context.dependencies.type' })}
+ |
+
+ {formatMessage({ id: 'sessionDetail.context.dependencies.to' })}
+ |
+
+
+
+ {dependencies.map((dep, index) => (
+
+ | {dep.from} |
+
+ {dep.type}
+ |
+ {dep.to} |
+
+ ))}
+
+
+
+
+ );
+}
+
+interface ExternalDependenciesSectionProps {
+ dependencies: ExternalDependency[];
+}
+
+function ExternalDependenciesSection({ dependencies }: ExternalDependenciesSectionProps) {
+ const { formatMessage } = useIntl();
+
+ return (
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.dependencies.external' })} ({dependencies.length})
+
+
+ {dependencies.map((dep, index) => (
+
+ {dep.package}
+ {dep.version && @{dep.version}}
+
+ ))}
+
+ {dependencies.some(d => d.usage) && (
+
+ {dependencies
+ .filter(d => d.usage)
+ .map((dep, index) => (
+
+ {dep.package}: {dep.usage}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/ccw/frontend/src/components/session-detail/context/ExplorationCollapsible.tsx b/ccw/frontend/src/components/session-detail/context/ExplorationCollapsible.tsx
new file mode 100644
index 00000000..d901c9fa
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/ExplorationCollapsible.tsx
@@ -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 (
+
+
+
+ {icon}
+ {title}
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/ccw/frontend/src/components/session-detail/context/ExplorationsSection.tsx b/ccw/frontend/src/components/session-detail/context/ExplorationsSection.tsx
new file mode 100644
index 00000000..2c19a3d4
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/ExplorationsSection.tsx
@@ -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;
+}
+
+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 (
+
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.explorations.title' })}
+
+ ({data.manifest.exploration_count} {formatMessage({ id: 'sessionDetail.context.explorations.angles' })})
+
+
+
+
+
+ {explorationEntries.map(([angle, angleData]) => (
+
}
+ >
+
+
+ ))}
+
+
+
+ );
+}
+
+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: ,
+ 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: ,
+ label: formatMessage({ id: 'sessionDetail.context.explorations.relevantFiles' }),
+ data: data.relevant_files,
+ });
+ }
+
+ if (data.patterns && data.patterns.length > 0) {
+ sections.push({
+ key: 'patterns',
+ icon: ,
+ label: formatMessage({ id: 'sessionDetail.context.explorations.patterns' }),
+ data: data.patterns,
+ });
+ }
+
+ if (data.dependencies && data.dependencies.length > 0) {
+ sections.push({
+ key: 'dependencies',
+ icon: ,
+ 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: ,
+ label: formatMessage({ id: 'sessionDetail.context.explorations.integrationPoints' }),
+ data: data.integration_points,
+ });
+ }
+
+ if (data.testing && data.testing.length > 0) {
+ sections.push({
+ key: 'testing',
+ icon: ,
+ label: formatMessage({ id: 'sessionDetail.context.explorations.testing' }),
+ data: data.testing,
+ });
+ }
+
+ if (sections.length === 0) {
+ return No data available
;
+ }
+
+ return (
+
+ {sections.map((section) => (
+
+
{section.icon}
+
+
+ {section.label}
+
+
+
+
+ ))}
+
+ );
+}
+
+function formatAngleTitle(angle: string): string {
+ return angle
+ .replace(/_/g, ' ')
+ .replace(/([A-Z])/g, ' $1')
+ .trim()
+ .toLowerCase()
+ .replace(/^\w/, (c) => c.toUpperCase());
+}
diff --git a/ccw/frontend/src/components/session-detail/context/FieldRenderer.tsx b/ccw/frontend/src/components/session-detail/context/FieldRenderer.tsx
new file mode 100644
index 00000000..b6478bbe
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/FieldRenderer.tsx
@@ -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 -;
+ }
+
+ const detectedType = type === 'auto' ? detectType(value) : type;
+
+ switch (detectedType) {
+ case 'array':
+ return ;
+ case 'object':
+ return } className={className} />;
+ case 'files':
+ return } className={className} />;
+ case 'tags':
+ return ;
+ default:
+ return ;
+ }
+}
+
+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 {value};
+}
+
+function ArrayRenderer({ value, className }: { value: unknown[]; className?: string }) {
+ if (value.length === 0) {
+ return Empty;
+ }
+
+ return (
+
+ {value.map((item, index) => (
+ -
+ {index + 1}.
+ {String(item)}
+
+ ))}
+
+ );
+}
+
+function ObjectRenderer({ value, className }: { value: Record; className?: string }) {
+ const entries = Object.entries(value).filter(([_, v]) => v !== null && v !== undefined);
+
+ if (entries.length === 0) {
+ return Empty;
+ }
+
+ return (
+
+ {entries.map(([key, val]) => (
+
+
+ {formatLabel(key)}:
+
+
+ {typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val)}
+
+
+ ))}
+
+ );
+}
+
+function FilesRenderer({ value, className }: { value: Array<{ path: string }>; className?: string }) {
+ if (value.length === 0) {
+ return No files;
+ }
+
+ return (
+
+ {value.map((file, index) => (
+
+
+ {file.path}
+
+ ))}
+
+ );
+}
+
+function TagsRenderer({ value, className }: { value: string[]; className?: string }) {
+ if (value.length === 0) {
+ return No tags;
+ }
+
+ return (
+
+ {value.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+ );
+}
+
+function formatLabel(key: string): string {
+ return key
+ .replace(/_/g, ' ')
+ .replace(/([A-Z])/g, ' $1')
+ .trim()
+ .toLowerCase()
+ .replace(/^\w/, (c) => c.toUpperCase());
+}
diff --git a/ccw/frontend/src/components/session-detail/context/TestContextCard.tsx b/ccw/frontend/src/components/session-detail/context/TestContextCard.tsx
new file mode 100644
index 00000000..a72b6184
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/TestContextCard.tsx
@@ -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;
+ 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 (
+
+
+
+
+ {formatMessage({ id: 'sessionDetail.context.testContext.title' })}
+ {testCount > 0 && (
+ {testCount} {formatMessage({ id: 'sessionDetail.context.testContext.tests' })}
+ )}
+
+
+
+ {/* Stats Row */}
+ {(testCount > 0 || markerCount > 0) && (
+
+ {testCount > 0 && (
+
+
+
+ {testCount} {formatMessage({ id: 'sessionDetail.context.testContext.existingTests' })}
+
+
+ )}
+ {markerCount > 0 && (
+
+
+
+ {markerCount} {formatMessage({ id: 'sessionDetail.context.testContext.markers' })}
+
+
+ )}
+
+ )}
+
+ {/* Framework Cards */}
+ {data.frameworks && (
+
+ )}
+
+ {/* Test Markers */}
+ {data.test_markers && data.test_markers.length > 0 && (
+
+
+ {formatMessage({ id: 'sessionDetail.context.testContext.markers' })}
+
+
+ {data.test_markers.map((marker, index) => (
+
+ {marker}
+
+ ))}
+
+
+ )}
+
+ {/* Coverage Config */}
+ {data.coverage_config && Object.keys(data.coverage_config).length > 0 && (
+
+
+ {formatMessage({ id: 'sessionDetail.context.testContext.coverage' })}
+
+
+
+ )}
+
+ {/* Existing Tests List */}
+ {data.existing_tests && data.existing_tests.length > 0 && (
+
+
+ {formatMessage({ id: 'sessionDetail.context.testContext.existingTests' })}
+
+
+
+ )}
+
+
+ );
+}
+
+interface FrameworkSectionProps {
+ frameworks: FrameworkConfig;
+}
+
+function FrameworkSection({ frameworks }: FrameworkSectionProps) {
+ const { formatMessage } = useIntl();
+
+ return (
+
+ {frameworks.backend && (
+
+ )}
+ {frameworks.frontend && (
+
+ )}
+
+ );
+}
+
+interface FrameworkCardProps {
+ title: string;
+ framework: TestFramework;
+}
+
+function FrameworkCard({ title, framework }: FrameworkCardProps) {
+ const { formatMessage } = useIntl();
+
+ return (
+
+
{title}
+
+ {framework.name && (
+
+ {formatMessage({ id: 'sessionDetail.context.testContext.framework' })}: {framework.name}
+
+ )}
+ {framework.plugins && framework.plugins.length > 0 && (
+
+ {framework.plugins.map((plugin, index) => (
+
+ {plugin}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/ccw/frontend/src/components/session-detail/context/index.ts b/ccw/frontend/src/components/session-detail/context/index.ts
new file mode 100644
index 00000000..e2771508
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/context/index.ts
@@ -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';
diff --git a/ccw/frontend/src/components/session-detail/tasks/BulkActionButton.tsx b/ccw/frontend/src/components/session-detail/tasks/BulkActionButton.tsx
new file mode 100644
index 00000000..28f6e483
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/tasks/BulkActionButton.tsx
@@ -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 {
+ 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 (
+
+ );
+}
diff --git a/ccw/frontend/src/components/session-detail/tasks/TaskStatsBar.tsx b/ccw/frontend/src/components/session-detail/tasks/TaskStatsBar.tsx
new file mode 100644
index 00000000..a0417598
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/tasks/TaskStatsBar.tsx
@@ -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;
+ onMarkAllInProgress?: () => void | Promise;
+ onMarkAllCompleted?: () => void | Promise;
+ 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 (
+
+ {/* Statistics */}
+
+
+
+ {completed} {formatMessage({ id: 'sessionDetail.tasks.completed' })}
+
+
+
+ {inProgress} {formatMessage({ id: 'sessionDetail.tasks.inProgress' })}
+
+
+
+ {pending} {formatMessage({ id: 'sessionDetail.tasks.pending' })}
+
+
+
+ {/* Bulk Action Buttons */}
+
+ {onMarkAllPending && (
+
+ )}
+ {onMarkAllInProgress && (
+
+ )}
+ {onMarkAllCompleted && (
+
+ )}
+
+
+ );
+}
diff --git a/ccw/frontend/src/components/session-detail/tasks/TaskStatusDropdown.tsx b/ccw/frontend/src/components/session-detail/tasks/TaskStatusDropdown.tsx
new file mode 100644
index 00000000..fbb462c5
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/tasks/TaskStatusDropdown.tsx
@@ -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;
+ 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 (
+
+
+
+ {isChanging ? (
+
+ ) : (
+
+ )}
+ {formatMessage({ id: currentConfig.label })}
+
+
+
+ {(Object.keys(statusConfig) as TaskStatus[]).map((status) => {
+ const config = statusConfig[status];
+ const Icon = config.icon;
+ return (
+ handleStatusChange(status)}
+ disabled={status === currentStatus || isChanging}
+ className="gap-2"
+ >
+
+ {formatMessage({ id: config.label })}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/ccw/frontend/src/components/session-detail/tasks/index.ts b/ccw/frontend/src/components/session-detail/tasks/index.ts
new file mode 100644
index 00000000..bfc3e006
--- /dev/null
+++ b/ccw/frontend/src/components/session-detail/tasks/index.ts
@@ -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';
diff --git a/ccw/frontend/src/components/shared/MarkdownModal.tsx b/ccw/frontend/src/components/shared/MarkdownModal.tsx
new file mode 100644
index 00000000..73e67def
--- /dev/null
+++ b/ccw/frontend/src/components/shared/MarkdownModal.tsx
@@ -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;
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'success';
+ disabled?: boolean;
+}
+
+// ========================================
+// Component
+// ========================================
+
+/**
+ * Modal for viewing markdown, JSON, or text content
+ *
+ * @example
+ * ```tsx
+ * 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 (
+
+
+ Loading...
+
+ );
+ }
+
+ if (!content) {
+ return (
+
+ );
+ }
+
+ switch (contentType) {
+ case 'markdown':
+ return (
+
+ );
+ case 'json':
+ return (
+
+ {JSON.stringify(JSON.parse(content), null, 2)}
+
+ );
+ case 'text':
+ return (
+
+ {content}
+
+ );
+ default:
+ return {content};
+ }
+ };
+
+ 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 (
+
+ );
+}
+
+// ========================================
+// Exports
+// ========================================
+
+export default MarkdownModal;
diff --git a/ccw/frontend/src/components/ui/index.ts b/ccw/frontend/src/components/ui/index.ts
index c969a245..49a688d6 100644
--- a/ccw/frontend/src/components/ui/index.ts
+++ b/ccw/frontend/src/components/ui/index.ts
@@ -9,6 +9,9 @@ export type { ButtonProps } from "./Button";
export { Input } from "./Input";
export type { InputProps } from "./Input";
+// Checkbox
+export { Checkbox } from "./Checkbox";
+
// Select (Radix)
export {
Select,
diff --git a/ccw/frontend/src/hooks/index.ts b/ccw/frontend/src/hooks/index.ts
index 540fe636..cbcc966a 100644
--- a/ccw/frontend/src/hooks/index.ts
+++ b/ccw/frontend/src/hooks/index.ts
@@ -195,4 +195,55 @@ export {
} from './useWorkspaceQueryKeys';
export type {
WorkspaceQueryKeys,
-} from './useWorkspaceQueryKeys';
\ No newline at end of file
+} 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';
\ No newline at end of file
diff --git a/ccw/frontend/src/hooks/useCli.ts b/ccw/frontend/src/hooks/useCli.ts
index 0e95baf3..1d181af8 100644
--- a/ccw/frontend/src/hooks/useCli.ts
+++ b/ccw/frontend/src/hooks/useCli.ts
@@ -388,13 +388,11 @@ export function useRules(options: UseRulesOptions = {}): UseRulesReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
- const queryEnabled = enabled && !!projectPath;
-
const query = useQuery({
queryKey: workspaceQueryKeys.rulesList(projectPath),
queryFn: () => fetchRules(projectPath),
staleTime,
- enabled: queryEnabled,
+ enabled: enabled, // Remove projectPath requirement
retry: 2,
});
diff --git a/ccw/frontend/src/hooks/useCodexLens.test.tsx b/ccw/frontend/src/hooks/useCodexLens.test.tsx
new file mode 100644
index 00000000..771c6de1
--- /dev/null
+++ b/ccw/frontend/src/hooks/useCodexLens.test.tsx
@@ -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 {children};
+}
+
+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);
+ });
+ });
+});
diff --git a/ccw/frontend/src/hooks/useCodexLens.ts b/ccw/frontend/src/hooks/useCodexLens.ts
new file mode 100644
index 00000000..aac5ecfd
--- /dev/null
+++ b/ccw/frontend/src/hooks/useCodexLens.ts
@@ -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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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 | undefined;
+ settings: Record | undefined;
+ raw: string | undefined;
+ isLoading: boolean;
+ error: Error | null;
+ refetch: () => Promise;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+ 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;
+ 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,
+ };
+}
diff --git a/ccw/frontend/src/hooks/useCommands.ts b/ccw/frontend/src/hooks/useCommands.ts
index 5f1e53f9..c85f0a59 100644
--- a/ccw/frontend/src/hooks/useCommands.ts
+++ b/ccw/frontend/src/hooks/useCommands.ts
@@ -52,13 +52,12 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
- const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: commandsKeys.list(filter),
queryFn: () => fetchCommands(projectPath),
staleTime,
- enabled: queryEnabled,
+ enabled: enabled, // Remove projectPath requirement
retry: 2,
});
diff --git a/ccw/frontend/src/hooks/useIssues.ts b/ccw/frontend/src/hooks/useIssues.ts
index 5af2ff63..78b9172b 100644
--- a/ccw/frontend/src/hooks/useIssues.ts
+++ b/ccw/frontend/src/hooks/useIssues.ts
@@ -15,8 +15,10 @@ import {
deactivateQueue,
deleteQueue as deleteQueueApi,
mergeQueues as mergeQueuesApi,
+ splitQueue as splitQueueApi,
fetchDiscoveries,
fetchDiscoveryFindings,
+ exportDiscoveryFindingsAsIssues,
type Issue,
type IssueQueue,
type IssuesResponse,
@@ -306,10 +308,12 @@ export interface UseQueueMutationsReturn {
deactivateQueue: () => Promise;
deleteQueue: (queueId: string) => Promise;
mergeQueues: (sourceId: string, targetId: string) => Promise;
+ splitQueue: (sourceQueueId: string, itemIds: string[]) => Promise;
isActivating: boolean;
isDeactivating: boolean;
isDeleting: boolean;
isMerging: boolean;
+ isSplitting: 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 {
activateQueue: activateMutation.mutateAsync,
deactivateQueue: deactivateMutation.mutateAsync,
deleteQueue: deleteMutation.mutateAsync,
mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }),
+ splitQueue: (sourceQueueId, itemIds) => splitMutation.mutateAsync({ sourceQueueId, itemIds }),
isActivating: activateMutation.isPending,
isDeactivating: deactivateMutation.isPending,
isDeleting: deleteMutation.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';
type?: string;
search?: string;
+ exported?: boolean;
+ hasIssue?: boolean;
}
export interface UseIssueDiscoveryReturn {
@@ -380,6 +396,8 @@ export interface UseIssueDiscoveryReturn {
selectSession: (sessionId: string) => void;
refetchSessions: () => void;
exportFindings: () => void;
+ exportSelectedFindings: (findingIds: string[]) => Promise<{ success: boolean; message?: string; exported?: number }>;
+ isExporting: boolean;
}
export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIssueDiscoveryReturn {
@@ -388,6 +406,7 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
const projectPath = useWorkflowStore(selectProjectPath);
const [activeSessionId, setActiveSessionId] = useState(null);
const [filters, setFilters] = useState({});
+ const [isExporting, setIsExporting] = useState(false);
const sessionsQuery = useQuery({
queryKey: workspaceQueryKeys.discoveries(projectPath),
@@ -426,6 +445,14 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
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;
}, [findingsQuery.data, filters]);
@@ -449,6 +476,26 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
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 {
sessions: sessionsQuery.data ?? [],
activeSession,
@@ -464,5 +511,7 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
sessionsQuery.refetch();
},
exportFindings,
+ exportSelectedFindings,
+ isExporting,
};
}
diff --git a/ccw/frontend/src/hooks/useSkills.ts b/ccw/frontend/src/hooks/useSkills.ts
index 41e248ca..0874cf3c 100644
--- a/ccw/frontend/src/hooks/useSkills.ts
+++ b/ccw/frontend/src/hooks/useSkills.ts
@@ -58,14 +58,11 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
- // Only enable query when projectPath is available
- const queryEnabled = enabled && !!projectPath;
-
const query = useQuery({
queryKey: workspaceQueryKeys.skillsList(projectPath),
queryFn: () => fetchSkills(projectPath),
staleTime,
- enabled: queryEnabled,
+ enabled: enabled, // Remove projectPath requirement - API works without it
retry: 2,
});
diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts
index 80025fdc..8459f83e 100644
--- a/ccw/frontend/src/lib/api.ts
+++ b/ccw/frontend/src/lib/api.ts
@@ -146,13 +146,16 @@ async function fetchApi(
status: response.status,
};
- try {
- const body = await response.json();
- if (body.message) error.message = body.message;
- if (body.code) error.code = body.code;
- } catch (parseError) {
- // Log parse errors instead of silently ignoring
- console.warn('[API] Failed to parse error response:', parseError);
+ // Only try to parse JSON if the content type indicates JSON
+ const contentType = response.headers.get('content-type');
+ if (contentType && contentType.includes('application/json')) {
+ try {
+ const body = await response.json();
+ if (body.message) error.message = body.message;
+ if (body.code) error.code = body.code;
+ } catch (parseError) {
+ // Silently ignore JSON parse errors for non-JSON responses
+ }
}
throw error;
@@ -599,12 +602,26 @@ export interface Issue {
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 {
tasks: string[];
solutions: string[];
conflicts: string[];
execution_groups: string[];
- grouped_items: Record;
+ grouped_items: Record;
}
export interface IssuesResponse {
@@ -683,6 +700,37 @@ export async function deleteIssue(issueId: string): Promise {
});
}
+/**
+ * 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 {
+ 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(url, {
+ method: 'POST',
+ });
+}
+
/**
* 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 {
+ return fetchApi(`/api/queue/split?path=${encodeURIComponent(projectPath)}`, {
+ method: 'POST',
+ body: JSON.stringify({ sourceQueueId, itemIds }),
+ });
+}
+
// ========== Discovery API ==========
export interface DiscoverySession {
@@ -743,14 +801,42 @@ export interface Finding {
line?: number;
code_snippet?: 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 {
const url = projectPath
? `/api/discoveries?path=${encodeURIComponent(projectPath)}`
: '/api/discoveries';
- const data = await fetchApi<{ sessions?: DiscoverySession[] }>(url);
- return data.sessions ?? [];
+ const data = await fetchApi<{ discoveries?: any[]; sessions?: DiscoverySession[] }>(url);
+
+ // 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(
@@ -774,6 +860,27 @@ export async function fetchDiscoveryFindings(
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 ==========
export interface Skill {
@@ -796,10 +903,30 @@ export interface SkillsResponse {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchSkills(projectPath?: string): Promise {
- const url = projectPath ? `/api/skills?path=${encodeURIComponent(projectPath)}` : '/api/skills';
- const data = await fetchApi<{ skills?: Skill[] }>(url);
+ // Try with project path first, fall back to global on 403/404
+ if (projectPath) {
+ try {
+ const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
+ const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url);
+ const allSkills = [...(data.projectSkills ?? []), ...(data.userSkills ?? [])];
+ 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 {
- skills: data.skills ?? [],
+ skills: data.skills ?? allSkills,
};
}
@@ -834,10 +961,30 @@ export interface CommandsResponse {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchCommands(projectPath?: string): Promise {
- const url = projectPath ? `/api/commands?path=${encodeURIComponent(projectPath)}` : '/api/commands';
- const data = await fetchApi<{ commands?: Command[] }>(url);
+ // Try with project path first, fall back to global on 403/404
+ 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 {
- commands: data.commands ?? [],
+ commands: data.commands ?? allCommands,
};
}
@@ -864,12 +1011,36 @@ export interface MemoryResponse {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchMemories(projectPath?: string): Promise {
- 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<{
memories?: CoreMemory[];
totalSize?: number;
claudeMdCount?: number;
- }>(url);
+ }>('/api/memory');
return {
memories: data.memories ?? [],
totalSize: data.totalSize ?? 0,
@@ -1027,6 +1198,65 @@ export interface SessionDetailContext {
tech_stack?: 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;
+ 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;
+ };
}
export interface SessionDetailResponse {
@@ -1136,6 +1366,47 @@ export async function deleteAllHistory(): Promise {
});
}
+// ========== 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)
*/
@@ -1728,10 +1999,30 @@ export async function installHookTemplate(templateId: string): Promise {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchRules(projectPath?: string): Promise {
- const url = projectPath ? `/api/rules?path=${encodeURIComponent(projectPath)}` : '/api/rules';
- const data = await fetchApi<{ rules?: Rule[] }>(url);
+ // Try with project path first, fall back to global on 403/404
+ 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 {
- rules: data.rules ?? [],
+ rules: data.rules ?? allRules,
};
}
@@ -2091,3 +2382,428 @@ export async function fetchGraphImpact(request: GraphImpactRequest): Promise(`/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;
+ raw?: string;
+ settings?: Record;
+}
+
+/**
+ * Update environment request
+ */
+export interface CodexLensUpdateEnvRequest {
+ env: Record;
+}
+
+/**
+ * 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 {
+ return fetchApi('/api/codexlens/dashboard-init');
+}
+
+/**
+ * Fetch CodexLens venv status
+ */
+export async function fetchCodexLensStatus(): Promise {
+ return fetchApi('/api/codexlens/status');
+}
+
+/**
+ * Fetch CodexLens workspace index status
+ */
+export async function fetchCodexLensWorkspaceStatus(projectPath: string): Promise {
+ const params = new URLSearchParams();
+ params.append('path', projectPath);
+ return fetchApi(`/api/codexlens/workspace-status?${params.toString()}`);
+}
+
+/**
+ * Fetch CodexLens configuration
+ */
+export async function fetchCodexLensConfig(): Promise {
+ return fetchApi('/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 {
+ return fetchApi('/api/codexlens/bootstrap', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+}
+
+/**
+ * Uninstall CodexLens
+ */
+export async function uninstallCodexLens(): Promise {
+ return fetchApi('/api/codexlens/uninstall', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+}
+
+/**
+ * Fetch CodexLens models list
+ */
+export async function fetchCodexLensModels(): Promise {
+ return fetchApi('/api/codexlens/models');
+}
+
+/**
+ * Fetch CodexLens model info by profile
+ */
+export async function fetchCodexLensModelInfo(profile: string): Promise {
+ const params = new URLSearchParams();
+ params.append('profile', profile);
+ return fetchApi(`/api/codexlens/models/info?${params.toString()}`);
+}
+
+/**
+ * Download CodexLens model by profile
+ */
+export async function downloadCodexLensModel(profile: string): Promise {
+ return fetchApi('/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 {
+ return fetchApi('/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 {
+ return fetchApi('/api/codexlens/models/delete', {
+ method: 'POST',
+ body: JSON.stringify({ profile }),
+ });
+}
+
+/**
+ * Delete CodexLens model by cache path
+ */
+export async function deleteCodexLensModelByPath(cachePath: string): Promise {
+ return fetchApi('/api/codexlens/models/delete-path', {
+ method: 'POST',
+ body: JSON.stringify({ cache_path: cachePath }),
+ });
+}
+
+/**
+ * Fetch CodexLens environment variables
+ */
+export async function fetchCodexLensEnv(): Promise {
+ return fetchApi('/api/codexlens/env');
+}
+
+/**
+ * Update CodexLens environment variables
+ */
+export async function updateCodexLensEnv(request: CodexLensUpdateEnvRequest): Promise {
+ return fetchApi('/api/codexlens/env', {
+ method: 'POST',
+ body: JSON.stringify(request),
+ });
+}
+
+/**
+ * Detect GPU support for CodexLens
+ */
+export async function fetchCodexLensGpuDetect(): Promise {
+ return fetchApi('/api/codexlens/gpu/detect');
+}
+
+/**
+ * Fetch available GPU devices
+ */
+export async function fetchCodexLensGpuList(): Promise {
+ return fetchApi('/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 {
+ return fetchApi('/api/codexlens/ignore-patterns');
+}
+
+/**
+ * Update CodexLens ignore patterns
+ */
+export async function updateCodexLensIgnorePatterns(request: CodexLensUpdateIgnorePatternsRequest): Promise {
+ return fetchApi('/api/codexlens/ignore-patterns', {
+ method: 'POST',
+ body: JSON.stringify(request),
+ });
+}
diff --git a/ccw/frontend/src/locales/en/cli-hooks.json b/ccw/frontend/src/locales/en/cli-hooks.json
index c0ecab39..e16c6759 100644
--- a/ccw/frontend/src/locales/en/cli-hooks.json
+++ b/ccw/frontend/src/locales/en/cli-hooks.json
@@ -66,6 +66,10 @@
"automation": "Automation"
},
"templates": {
+ "ccw-status-tracker": {
+ "name": "CCW Status Tracker",
+ "description": "Parse CCW status.json and display current/next command"
+ },
"ccw-notify": {
"name": "CCW Dashboard Notify",
"description": "Send notifications to CCW dashboard when files are written"
diff --git a/ccw/frontend/src/locales/en/codexlens.json b/ccw/frontend/src/locales/en/codexlens.json
new file mode 100644
index 00000000..c4b261bd
--- /dev/null
+++ b/ccw/frontend/src/locales/en/codexlens.json
@@ -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"
+ }
+ }
+}
diff --git a/ccw/frontend/src/locales/en/index.ts b/ccw/frontend/src/locales/en/index.ts
index 8eb692eb..e4a6d32a 100644
--- a/ccw/frontend/src/locales/en/index.ts
+++ b/ccw/frontend/src/locales/en/index.ts
@@ -23,6 +23,7 @@ import skills from './skills.json';
import cliManager from './cli-manager.json';
import cliMonitor from './cli-monitor.json';
import mcpManager from './mcp-manager.json';
+import codexlens from './codexlens.json';
import theme from './theme.json';
import executionMonitor from './execution-monitor.json';
import cliHooks from './cli-hooks.json';
@@ -77,9 +78,10 @@ export default {
...flattenMessages(reviewSession, 'reviewSession'),
...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'),
- ...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
+ ...flattenMessages(cliManager, 'cli-manager'),
...flattenMessages(cliMonitor, 'cliMonitor'),
...flattenMessages(mcpManager, 'mcp'),
+ ...flattenMessages(codexlens, 'codexlens'),
...flattenMessages(theme, 'theme'),
...flattenMessages(cliHooks, 'cliHooks'),
...flattenMessages(executionMonitor, 'executionMonitor'),
diff --git a/ccw/frontend/src/locales/en/issues.json b/ccw/frontend/src/locales/en/issues.json
index 1f25f97d..b5ea275e 100644
--- a/ccw/frontend/src/locales/en/issues.json
+++ b/ccw/frontend/src/locales/en/issues.json
@@ -97,6 +97,23 @@
"noSessions": "No sessions found",
"noSessionsDescription": "Start a new discovery session to begin",
"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": {
"totalSessions": "Total Sessions",
"completed": "Completed",
@@ -129,8 +146,31 @@
"type": {
"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",
- "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": {
"findings": "Findings",
diff --git a/ccw/frontend/src/locales/en/navigation.json b/ccw/frontend/src/locales/en/navigation.json
index c906d9d4..26da987e 100644
--- a/ccw/frontend/src/locales/en/navigation.json
+++ b/ccw/frontend/src/locales/en/navigation.json
@@ -16,6 +16,7 @@
"prompts": "Prompt History",
"settings": "Settings",
"mcp": "MCP Servers",
+ "codexlens": "CodexLens",
"endpoints": "CLI Endpoints",
"installations": "Installations",
"help": "Help",
diff --git a/ccw/frontend/src/locales/en/session-detail.json b/ccw/frontend/src/locales/en/session-detail.json
index 55f6bdcf..d42c726e 100644
--- a/ccw/frontend/src/locales/en/session-detail.json
+++ b/ccw/frontend/src/locales/en/session-detail.json
@@ -6,13 +6,25 @@
"tabs": {
"tasks": "Tasks",
"context": "Context",
- "summary": "Summary"
+ "summary": "Summary",
+ "implPlan": "IMPL Plan",
+ "conflict": "Conflict",
+ "review": "Review"
},
"tasks": {
"completed": "completed",
"inProgress": "in progress",
"pending": "pending",
"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": {
"pending": "Pending",
"inProgress": "In Progress",
@@ -36,15 +48,114 @@
"empty": {
"title": "No Context Available",
"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": {
+ "default": "Summary",
"title": "Session Summary",
+ "lines": "lines",
"empty": {
"title": "No Summary Available",
"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": {
"created": "Created",
"updated": "Updated",
diff --git a/ccw/frontend/src/locales/zh/cli-hooks.json b/ccw/frontend/src/locales/zh/cli-hooks.json
index fdf13987..155f5ed9 100644
--- a/ccw/frontend/src/locales/zh/cli-hooks.json
+++ b/ccw/frontend/src/locales/zh/cli-hooks.json
@@ -66,6 +66,10 @@
"automation": "่ชๅจๅ"
},
"templates": {
+ "ccw-status-tracker": {
+ "name": "CCW ็ถๆ่ฟฝ่ธชๅจ",
+ "description": "่งฃๆ CCW status.json ๅนถๆพ็คบๅฝๅ/ไธไธไธชๅฝไปค"
+ },
"ccw-notify": {
"name": "CCW ้ขๆฟ้็ฅ",
"description": "ๅฝๆไปถ่ขซๅๅ
ฅๆถๅ CCW ้ขๆฟๅ้้็ฅ"
diff --git a/ccw/frontend/src/locales/zh/codexlens.json b/ccw/frontend/src/locales/zh/codexlens.json
new file mode 100644
index 00000000..650e0244
--- /dev/null
+++ b/ccw/frontend/src/locales/zh/codexlens.json
@@ -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": "ๅฐ่ฏ่ฐๆดๆ็ดขๆ็ญ้ๆกไปถ"
+ }
+ }
+}
diff --git a/ccw/frontend/src/locales/zh/common.json b/ccw/frontend/src/locales/zh/common.json
index ab0ab25c..9a7990cf 100644
--- a/ccw/frontend/src/locales/zh/common.json
+++ b/ccw/frontend/src/locales/zh/common.json
@@ -36,7 +36,10 @@
"submit": "ๆไบค",
"reset": "้็ฝฎ",
"resetDesc": "ๅฐๆๆ็จๆทๅๅฅฝ้็ฝฎไธบ้ป่ฎคๅผใๆญคๆไฝๆ ๆณๆค้ใ",
- "saving": "Saving...",
+ "saving": "ไฟๅญไธญ...",
+ "deleting": "ๅ ้คไธญ...",
+ "merging": "ๅๅนถไธญ...",
+ "splitting": "ๆๅไธญ...",
"resetConfirm": "็กฎๅฎ่ฆๅฐๆๆ่ฎพ็ฝฎ้็ฝฎไธบ้ป่ฎคๅผๅ๏ผ",
"resetToDefaults": "้็ฝฎไธบ้ป่ฎคๅผ",
"enable": "ๅฏ็จ",
@@ -51,7 +54,8 @@
"clearAll": "ๆธ
้คๅ
จ้จ",
"select": "้ๆฉ",
"selectAll": "ๅ
จ้",
- "deselectAll": "ๅๆถๅ
จ้"
+ "deselectAll": "ๅๆถๅ
จ้",
+ "openMenu": "ๆๅผ่ๅ"
},
"status": {
"active": "ๆดป่ท",
diff --git a/ccw/frontend/src/locales/zh/index.ts b/ccw/frontend/src/locales/zh/index.ts
index 2597eab3..517d57fd 100644
--- a/ccw/frontend/src/locales/zh/index.ts
+++ b/ccw/frontend/src/locales/zh/index.ts
@@ -23,6 +23,7 @@ import skills from './skills.json';
import cliManager from './cli-manager.json';
import cliMonitor from './cli-monitor.json';
import mcpManager from './mcp-manager.json';
+import codexlens from './codexlens.json';
import theme from './theme.json';
import executionMonitor from './execution-monitor.json';
import cliHooks from './cli-hooks.json';
@@ -77,9 +78,10 @@ export default {
...flattenMessages(reviewSession, 'reviewSession'),
...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'),
- ...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
+ ...flattenMessages(cliManager, 'cli-manager'),
...flattenMessages(cliMonitor, 'cliMonitor'),
...flattenMessages(mcpManager, 'mcp'),
+ ...flattenMessages(codexlens, 'codexlens'),
...flattenMessages(theme, 'theme'),
...flattenMessages(cliHooks, 'cliHooks'),
...flattenMessages(executionMonitor, 'executionMonitor'),
diff --git a/ccw/frontend/src/locales/zh/issues.json b/ccw/frontend/src/locales/zh/issues.json
index 0918cf98..f1381ed9 100644
--- a/ccw/frontend/src/locales/zh/issues.json
+++ b/ccw/frontend/src/locales/zh/issues.json
@@ -61,29 +61,130 @@
"updatedAt": "ๆดๆฐๆถ้ด",
"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": {
"title": "้ๅ",
"pageTitle": "้ฎ้ข้ๅ",
"description": "็ฎก็้ฎ้ขๆง่ก้ๅๅๆง่ก็ป",
+ "status": {
+ "pending": "ๅพ
ๅค็",
+ "ready": "ๅฐฑ็ปช",
+ "executing": "ๆง่กไธญ",
+ "completed": "ๅทฒๅฎๆ",
+ "failed": "ๅคฑ่ดฅ",
+ "blocked": "ๅทฒ้ปๅก",
+ "active": "ๆดปๅจ",
+ "inactive": "้ๆดปๅจ"
+ },
"stats": {
"totalItems": "ๆป้กน็ฎ",
"groups": "ๆง่ก็ป",
"tasks": "ไปปๅก",
- "solutions": "่งฃๅณๆนๆก"
+ "solutions": "่งฃๅณๆนๆก",
+ "items": "้กน็ฎ",
+ "executionGroups": "ๆง่ก็ป"
},
"actions": {
"activate": "ๆฟๆดป",
"deactivate": "ๅ็จ",
"delete": "ๅ ้ค",
"merge": "ๅๅนถ",
+ "split": "ๆๅ",
"confirmDelete": "็กฎๅฎ่ฆๅ ้คๆญค้ๅๅ๏ผ"
},
"executionGroup": "ๆง่ก็ป",
+ "executionGroups": "ๆง่ก็ป",
+ "parallelGroup": "ๅนถ่ก็ป",
+ "sequentialGroup": "้กบๅบ็ป",
+ "items": "้กน็ฎ",
+ "itemCount": "{count} ้กน",
+ "groups": "็ป",
"parallel": "ๅนถ่ก",
"sequential": "้กบๅบ",
"emptyState": "ๆ ้ๅๆฐๆฎ",
+ "empty": "ๆ ๆฐๆฎ",
"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": {
"title": "ๅ็ฐ",
@@ -97,6 +198,23 @@
"noSessions": "ๆชๅ็ฐไผ่ฏ",
"noSessionsDescription": "ๅฏๅจๆฐ็้ฎ้ขๅ็ฐไผ่ฏไปฅๅผๅง",
"findingsDetail": "ๅ็ฐ่ฏฆๆ
",
+ "selectSession": "้ๆฉไผ่ฏไปฅๆฅ็ๅ็ฐ",
+ "sessionId": "ไผ่ฏID",
+ "name": "ๅ็งฐ",
+ "status": "็ถๆ",
+ "createdAt": "ๅๅปบๆถ้ด",
+ "completedAt": "ๅฎๆๆถ้ด",
+ "progress": "่ฟๅบฆ",
+ "findingsCount": "ๅ็ฐๆฐ้",
+ "export": "ๅฏผๅบJSON",
+ "exportSelected": "ๅฏผๅบ้ไธญ็ {count} ้กน",
+ "exporting": "ๅฏผๅบไธญ...",
+ "exportAsIssues": "ๅฏผๅบไธบ้ฎ้ข",
+ "severityBreakdown": "ไธฅ้็จๅบฆๅๅธ",
+ "typeBreakdown": "็ฑปๅๅๅธ",
+ "tabFindings": "ๅ็ฐ",
+ "tabProgress": "่ฟๅบฆ",
+ "tabInfo": "ไผ่ฏไฟกๆฏ",
"stats": {
"totalSessions": "ๆปไผ่ฏๆฐ",
"completed": "ๅทฒๅฎๆ",
@@ -124,13 +242,37 @@
"critical": "ไธฅ้",
"high": "้ซ",
"medium": "ไธญ",
- "low": "ไฝ"
+ "low": "ไฝ",
+ "unknown": "ๆช็ฅ"
},
"type": {
"all": "ๅ
จ้จ็ฑปๅ"
},
+ "exportedStatus": {
+ "all": "ๅ
จ้จๅฏผๅบ็ถๆ",
+ "exported": "ๅทฒๅฏผๅบ",
+ "notExported": "ๆชๅฏผๅบ"
+ },
+ "issueStatus": {
+ "all": "ๅ
จ้จ้ฎ้ข็ถๆ",
+ "hasIssue": "ๅทฒๅ
ณ่้ฎ้ข",
+ "noIssue": "ๆชๅ
ณ่้ฎ้ข"
+ },
"noFindings": "ๆชๅ็ฐ็ปๆ",
- "export": "ๅฏผๅบ"
+ "noFindingsDescription": "ๆฒกๆๆพๅฐๅน้
็ๅ็ฐ็ปๆ",
+ "searchPlaceholder": "ๆ็ดขๅ็ฐ...",
+ "filterBySeverity": "ๆไธฅ้็จๅบฆ็ญ้",
+ "filterByType": "ๆ็ฑปๅ็ญ้",
+ "filterByExported": "ๆๅฏผๅบ็ถๆ็ญ้",
+ "filterByIssue": "ๆๅ
ณ่้ฎ้ข็ญ้",
+ "allSeverities": "ๅ
จ้จไธฅ้็จๅบฆ",
+ "allTypes": "ๅ
จ้จ็ฑปๅ",
+ "showingCount": "ๆพ็คบ {count} ๆกๅ็ฐ",
+ "exported": "ๅทฒๅฏผๅบ",
+ "hasIssue": "ๅทฒๅ
ณ่",
+ "export": "ๅฏผๅบ",
+ "selectAll": "ๅ
จ้",
+ "deselectAll": "ๅๆถๅ
จ้"
},
"tabs": {
"findings": "ๅ็ฐ",
diff --git a/ccw/frontend/src/locales/zh/navigation.json b/ccw/frontend/src/locales/zh/navigation.json
index d14fe435..d4f90f32 100644
--- a/ccw/frontend/src/locales/zh/navigation.json
+++ b/ccw/frontend/src/locales/zh/navigation.json
@@ -16,6 +16,7 @@
"prompts": "ๆ็คบๅๅฒ",
"settings": "่ฎพ็ฝฎ",
"mcp": "MCP ๆๅกๅจ",
+ "codexlens": "CodexLens",
"endpoints": "CLI ็ซฏ็น",
"installations": "ๅฎ่ฃ
",
"help": "ๅธฎๅฉ",
diff --git a/ccw/frontend/src/locales/zh/session-detail.json b/ccw/frontend/src/locales/zh/session-detail.json
index cfeb1609..59be61bf 100644
--- a/ccw/frontend/src/locales/zh/session-detail.json
+++ b/ccw/frontend/src/locales/zh/session-detail.json
@@ -6,13 +6,25 @@
"tabs": {
"tasks": "ไปปๅก",
"context": "ไธไธๆ",
- "summary": "ๆ่ฆ"
+ "summary": "ๆ่ฆ",
+ "implPlan": "IMPL ่ฎกๅ",
+ "conflict": "ๅฒ็ช",
+ "review": "ๅฎกๆฅ"
},
"tasks": {
"completed": "ๅทฒๅฎๆ",
"inProgress": "่ฟ่กไธญ",
"pending": "ๅพ
ๅค็",
"blocked": "ๅทฒ้ปๅก",
+ "quickActions": {
+ "markAllPending": "ๅ
จ้จๅพ
ๅค็",
+ "markAllInProgress": "ๅ
จ้จ่ฟ่กไธญ",
+ "markAllCompleted": "ๅ
จ้จๅทฒๅฎๆ"
+ },
+ "statusUpdate": {
+ "success": "ไปปๅก็ถๆๆดๆฐๆๅ",
+ "error": "ๆดๆฐไปปๅก็ถๆๅคฑ่ดฅ"
+ },
"status": {
"pending": "ๅพ
ๅค็",
"inProgress": "่ฟ่กไธญ",
@@ -36,6 +48,59 @@
"empty": {
"title": "ๆๆ ไธไธๆ",
"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": {
@@ -45,6 +110,50 @@
"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": {
"created": "ๅๅปบๆถ้ด",
"updated": "ๆดๆฐๆถ้ด",
diff --git a/ccw/frontend/src/pages/CodexLensManagerPage.test.tsx b/ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
new file mode 100644
index 00000000..a0502d7e
--- /dev/null
+++ b/ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
@@ -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).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();
+
+ expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
+ expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
+ });
+
+ it('should render all tabs', () => {
+ render();
+
+ 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();
+
+ expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
+ });
+
+ it('should switch between tabs', async () => {
+ const user = userEvent.setup();
+ render();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText(/Bootstrap/i)).toBeInTheDocument();
+ });
+
+ it('should show not installed alert', () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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).mockReturnValue(false);
+ const uninstall = vi.fn().mockResolvedValue({ success: true });
+ vi.mocked(useCodexLensMutations).mockReturnValue({
+ ...mockMutations,
+ uninstall,
+ });
+
+ const user = userEvent.setup();
+ render();
+
+ 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();
+
+ // 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();
+
+ 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(, { 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(, { 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();
+
+ // Page should still render even with error
+ expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/ccw/frontend/src/pages/CodexLensManagerPage.tsx b/ccw/frontend/src/pages/CodexLensManagerPage.tsx
new file mode 100644
index 00000000..ae35aec8
--- /dev/null
+++ b/ccw/frontend/src/pages/CodexLensManagerPage.tsx
@@ -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 (
+
+ {/* Page Header */}
+
+
+
+
+ {formatMessage({ id: 'codexlens.title' })}
+
+
+ {formatMessage({ id: 'codexlens.description' })}
+
+
+
+
+ {!installed ? (
+
+ ) : (
+
+
+
+
+
+
+
+ {formatMessage({ id: 'codexlens.confirmUninstallTitle' })}
+
+
+ {formatMessage({ id: 'codexlens.confirmUninstall' })}
+
+
+
+
+ {formatMessage({ id: 'common.actions.cancel' })}
+
+
+ {isUninstalling
+ ? formatMessage({ id: 'codexlens.uninstalling' })
+ : formatMessage({ id: 'common.actions.confirm' })
+ }
+
+
+
+
+ )}
+
+
+
+ {/* Installation Status Alert */}
+ {!installed && !isLoading && (
+
+
+ {formatMessage({ id: 'codexlens.notInstalled' })}
+
+
+ )}
+
+ {/* Tabbed Interface */}
+
+
+
+ {formatMessage({ id: 'codexlens.tabs.overview' })}
+
+
+ {formatMessage({ id: 'codexlens.tabs.settings' })}
+
+
+ {formatMessage({ id: 'codexlens.tabs.models' })}
+
+
+ {formatMessage({ id: 'codexlens.tabs.advanced' })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default CodexLensManagerPage;
diff --git a/ccw/frontend/src/pages/DiscoveryPage.tsx b/ccw/frontend/src/pages/DiscoveryPage.tsx
index 330dc104..6bcd063c 100644
--- a/ccw/frontend/src/pages/DiscoveryPage.tsx
+++ b/ccw/frontend/src/pages/DiscoveryPage.tsx
@@ -25,6 +25,8 @@ export function DiscoveryPage() {
setFilters,
selectSession,
exportFindings,
+ exportSelectedFindings,
+ isExporting,
} = useIssueDiscovery({ refetchInterval: 3000 });
if (error) {
@@ -163,6 +165,8 @@ export function DiscoveryPage() {
filters={filters}
onFilterChange={setFilters}
onExport={exportFindings}
+ onExportSelected={exportSelectedFindings}
+ isExporting={isExporting}
/>
)}
diff --git a/ccw/frontend/src/pages/IssueHubPage.tsx b/ccw/frontend/src/pages/IssueHubPage.tsx
index b83b4afc..dfd30239 100644
--- a/ccw/frontend/src/pages/IssueHubPage.tsx
+++ b/ccw/frontend/src/pages/IssueHubPage.tsx
@@ -3,28 +3,219 @@
// ========================================
// Unified page for issues, queue, and discovery with tab navigation
+import { useState, useCallback, useRef } from 'react';
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 { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
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('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 (
+
+ );
+}
export function IssueHubPage() {
+ const { formatMessage } = useIntl();
const [searchParams, setSearchParams] = useSearchParams();
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) => {
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 (
+ <>
+
+
+
+ >
+ );
+
+ case 'queue':
+ return (
+ <>
+
+ >
+ );
+
+ case 'discovery':
+ return null; // Discovery panel has its own controls
+
+ default:
+ return null;
+ }
+ };
+
return (
-
+ {/* Header and action buttons on same row */}
+
+
+
+ {/* Action buttons - dynamic based on current tab */}
+ {renderActionButtons() && (
+
+ {renderActionButtons()}
+
+ )}
+
+
- {currentTab === 'issues' &&
}
+ {currentTab === 'issues' &&
setIsNewIssueOpen(true)} />}
{currentTab === 'queue' && }
{currentTab === 'discovery' && }
+
+
);
}
diff --git a/ccw/frontend/src/pages/SessionDetailPage.tsx b/ccw/frontend/src/pages/SessionDetailPage.tsx
index b248af98..16bf07b3 100644
--- a/ccw/frontend/src/pages/SessionDetailPage.tsx
+++ b/ccw/frontend/src/pages/SessionDetailPage.tsx
@@ -1,7 +1,7 @@
// ========================================
// 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 { useParams, useNavigate } from 'react-router-dom';
@@ -13,18 +13,24 @@ import {
Package,
FileText,
XCircle,
+ Ruler,
+ Scale,
+ Search,
} from 'lucide-react';
import { useSessionDetail } from '@/hooks/useSessionDetail';
import { TaskListTab } from './session-detail/TaskListTab';
import { ContextTab } from './session-detail/ContextTab';
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 { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
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
@@ -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 completedTasks = tasks.filter((t) => t.status === 'completed').length;
+ const hasReview = session.has_review || session.review;
return (
@@ -158,6 +165,20 @@ export function SessionDetailPage() {
{formatMessage({ id: 'sessionDetail.tabs.summary' })}
+
+
+ {formatMessage({ id: 'sessionDetail.tabs.implPlan' })}
+
+
+
+ {formatMessage({ id: 'sessionDetail.tabs.conflict' })}
+
+ {hasReview && (
+
+
+ {formatMessage({ id: 'sessionDetail.tabs.review' })}
+
+ )}
@@ -169,8 +190,22 @@ export function SessionDetailPage() {
-
+
+
+
+
+
+
+
+
+
+
+ {hasReview && (
+
+
+
+ )}
{/* Description (if exists) */}
diff --git a/ccw/frontend/src/pages/index.ts b/ccw/frontend/src/pages/index.ts
index 13b40782..93668dfc 100644
--- a/ccw/frontend/src/pages/index.ts
+++ b/ccw/frontend/src/pages/index.ts
@@ -33,3 +33,4 @@ export { RulesManagerPage } from './RulesManagerPage';
export { PromptHistoryPage } from './PromptHistoryPage';
export { ExplorerPage } from './ExplorerPage';
export { GraphExplorerPage } from './GraphExplorerPage';
+export { CodexLensManagerPage } from './CodexLensManagerPage';
diff --git a/ccw/frontend/src/pages/session-detail/ConflictTab.tsx b/ccw/frontend/src/pages/session-detail/ConflictTab.tsx
new file mode 100644
index 00000000..9add2357
--- /dev/null
+++ b/ccw/frontend/src/pages/session-detail/ConflictTab.tsx
@@ -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
;
+ 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 (
+
+
+
+ {formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
+
+
+ {formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ {formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
+
+
+ {formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
+
+
+ );
+ }
+
+ return (
+
+ {/* Resolved At */}
+ {conflicts.resolved_at && (
+
+
+
+ {formatMessage({ id: 'sessionDetail.conflict.resolvedAt' })}:{' '}
+ {new Date(conflicts.resolved_at).toLocaleString()}
+
+
+ )}
+
+ {/* User Decisions Section */}
+ {hasUserDecisions && (
+
+
+
+ {formatMessage({ id: 'sessionDetail.conflict.userDecisions' })}
+
+
+ {Object.entries(conflicts.user_decisions!).map(([key, decision], index) => (
+
+
+
+ {key}
+
+ {decision.choice}
+
+
+
+ {decision.description && (
+
+ {formatMessage({ id: 'sessionDetail.conflict.description' })}:{' '}
+ {decision.description}
+
+ )}
+ {decision.implications && (
+
+ {formatMessage({ id: 'sessionDetail.conflict.implications' })}:{' '}
+ {decision.implications}
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Resolved Conflicts Section */}
+ {hasResolvedConflicts && (
+
+
+
+ {formatMessage({ id: 'sessionDetail.conflict.resolvedConflicts' })}
+
+
+ {conflicts.resolved_conflicts!.map((conflict) => (
+
+
+
+
+
+ {conflict.id}
+ {conflict.category && (
+
+ {conflict.category}
+
+ )}
+
+ {conflict.brief && (
+
{conflict.brief}
+ )}
+
+
+
+ {conflict.strategy && (
+
+ {formatMessage({ id: 'sessionDetail.conflict.strategy' })}:{' '}
+ {conflict.strategy}
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/ccw/frontend/src/pages/session-detail/ContextTab.tsx b/ccw/frontend/src/pages/session-detail/ContextTab.tsx
index d02c91ec..3baf5525 100644
--- a/ccw/frontend/src/pages/session-detail/ContextTab.tsx
+++ b/ccw/frontend/src/pages/session-detail/ContextTab.tsx
@@ -15,6 +15,13 @@ import {
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { SessionDetailContext } from '@/lib/api';
+import {
+ ExplorationsSection,
+ AssetsCard,
+ DependenciesCard,
+ TestContextCard,
+ ConflictDetectionCard,
+} from '@/components/session-detail/context';
export interface ContextTabProps {
context?: SessionDetailContext;
@@ -44,12 +51,16 @@ export function ContextTab({ context }: ContextTabProps) {
const hasFocusPaths = context.focus_paths && context.focus_paths.length > 0;
const hasArtifacts = context.artifacts && context.artifacts.length > 0;
const hasSharedContext = context.shared_context;
+ const hasExtendedContext = context.context;
+ const hasExplorations = context.explorations;
if (
!hasRequirements &&
!hasFocusPaths &&
!hasArtifacts &&
- !hasSharedContext
+ !hasSharedContext &&
+ !hasExtendedContext &&
+ !hasExplorations
) {
return (
@@ -66,7 +77,7 @@ export function ContextTab({ context }: ContextTabProps) {
return (
- {/* Requirements */}
+ {/* Original Context Sections - Maintained for backward compatibility */}
{hasRequirements && (
@@ -90,7 +101,6 @@ export function ContextTab({ context }: ContextTabProps) {
)}
- {/* Focus Paths */}
{hasFocusPaths && (
@@ -113,7 +123,6 @@ export function ContextTab({ context }: ContextTabProps) {
)}
- {/* Artifacts */}
{hasArtifacts && (
@@ -133,7 +142,6 @@ export function ContextTab({ context }: ContextTabProps) {
)}
- {/* Shared Context */}
{hasSharedContext && (
@@ -142,7 +150,6 @@ export function ContextTab({ context }: ContextTabProps) {
{formatMessage({ id: 'sessionDetail.context.sharedContext' })}
- {/* Tech Stack */}
{context.shared_context!.tech_stack && context.shared_context!.tech_stack.length > 0 && (
@@ -158,7 +165,6 @@ export function ContextTab({ context }: ContextTabProps) {
)}
- {/* Conventions */}
{context.shared_context!.conventions && context.shared_context!.conventions.length > 0 && (
@@ -177,6 +183,25 @@ export function ContextTab({ context }: ContextTabProps) {
)}
+
+ {/* New Extended Context Sections from context-package.json */}
+ {hasExplorations && }
+
+ {hasExtendedContext && context.context!.assets && (
+
+ )}
+
+ {hasExtendedContext && context.context!.dependencies && (
+
+ )}
+
+ {hasExtendedContext && context.context!.test_context && (
+
+ )}
+
+ {hasExtendedContext && context.context!.conflict_detection && (
+
+ )}
);
}
diff --git a/ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx b/ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
new file mode 100644
index 00000000..a32b8d7c
--- /dev/null
+++ b/ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
@@ -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
+ *
+ * ```
+ */
+export function ImplPlanTab({ implPlan }: ImplPlanTabProps) {
+ const { formatMessage } = useIntl();
+ const [isModalOpen, setIsModalOpen] = React.useState(false);
+
+ if (!implPlan) {
+ return (
+
+
+
+ {formatMessage({ id: 'sessionDetail.implPlan.empty.title' })}
+
+
+ {formatMessage({ id: 'sessionDetail.implPlan.empty.message' })}
+
+
+ );
+ }
+
+ // Get preview (first 5 lines)
+ const lines = implPlan.split('\n');
+ const preview = lines.slice(0, 5).join('\n');
+ const hasMore = lines.length > 5;
+
+ return (
+ <>
+
+
+
+
+
+ {formatMessage({ id: 'sessionDetail.implPlan.title' })}
+
+
+
+
+
+
+ {preview}{hasMore && '\n...'}
+
+ {hasMore && (
+
+
+
+ )}
+
+
+
+ {/* Modal Viewer */}
+ setIsModalOpen(false)}
+ title="IMPL_PLAN.md"
+ content={implPlan}
+ contentType="markdown"
+ maxWidth="3xl"
+ />
+ >
+ );
+}
+
+// ========================================
+// Exports
+// ========================================
+
+export default ImplPlanTab;
diff --git a/ccw/frontend/src/pages/session-detail/ReviewTab.tsx b/ccw/frontend/src/pages/session-detail/ReviewTab.tsx
new file mode 100644
index 00000000..493dd4f2
--- /dev/null
+++ b/ccw/frontend/src/pages/session-detail/ReviewTab.tsx
@@ -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 ;
+ case 'medium':
+ return ;
+ case 'low':
+ return ;
+ default:
+ return null;
+ }
+}
+
+/**
+ * ReviewTab component - Display review findings by dimension
+ */
+export function ReviewTab({ review }: ReviewTabProps) {
+ const { formatMessage } = useIntl();
+ const [severityFilter, setSeverityFilter] = useState('all');
+
+ if (!review || !review.dimensions || review.dimensions.length === 0) {
+ return (
+
+
+
+ {formatMessage({ id: 'sessionDetail.review.empty.title' })}
+
+
+ {formatMessage({ id: 'sessionDetail.review.empty.message' })}
+
+
+ );
+ }
+
+ // 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 (
+
+
+
+ {formatMessage({ id: 'sessionDetail.review.noFindings.title' })}
+
+
+ {formatMessage({ id: 'sessionDetail.review.noFindings.message' })}
+
+
+ );
+ }
+
+ return (
+
+ {/* Severity Filter */}
+
+
+ {formatMessage({ id: 'sessionDetail.review.filterBySeverity' })}
+
+
+
+
+ {/* Dimensions with Findings */}
+ {filteredDimensions.map((dimension) => {
+ if (!dimension.findings || dimension.findings.length === 0) return null;
+
+ return (
+
+
+
+
{dimension.name}
+ {dimension.findings.length}
+
+
+ {dimension.summary && (
+ {dimension.summary}
+ )}
+
+
+ {dimension.findings.map((finding, findingIndex) => (
+
+
+
+
+
+ {getSeverityIcon(finding.severity)}
+ {finding.title}
+
+ {formatMessage({ id: `sessionDetail.review.severity.${finding.severity}` })}
+
+
+ {finding.location && (
+
{finding.location}
+ )}
+
+
+
+ {finding.description && (
+
+ {finding.description}
+
+ )}
+ {finding.code && (
+
+ {finding.code}
+
+ )}
+
+
+ ))}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/ccw/frontend/src/pages/session-detail/SummaryTab.tsx b/ccw/frontend/src/pages/session-detail/SummaryTab.tsx
index 365458a2..e5e18118 100644
--- a/ccw/frontend/src/pages/session-detail/SummaryTab.tsx
+++ b/ccw/frontend/src/pages/session-detail/SummaryTab.tsx
@@ -1,23 +1,60 @@
// ========================================
// 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 { FileText } from 'lucide-react';
-import { Card, CardContent } from '@/components/ui/Card';
+import { FileText, Eye } from 'lucide-react';
+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 {
summary?: string;
+ summaries?: SummaryItem[];
}
-/**
- * SummaryTab component - Display session summary
- */
-export function SummaryTab({ summary }: SummaryTabProps) {
- const { formatMessage } = useIntl();
+// ========================================
+// Component
+// ========================================
- if (!summary) {
+/**
+ * SummaryTab component - Display session summary/summaries with modal viewer
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function SummaryTab({ summary, summaries }: SummaryTabProps) {
+ const { formatMessage } = useIntl();
+ const [selectedSummary, setSelectedSummary] = React.useState(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 (
@@ -32,16 +69,97 @@ export function SummaryTab({ summary }: SummaryTabProps) {
}
return (
-
-
-
-
- {formatMessage({ id: 'sessionDetail.summary.title' })}
-
-
-
{summary}
+ <>
+
+ {summaryList.length === 1 ? (
+ // Single summary - inline display
+
+
+
+
+ {summaryList[0].name}
+
+
+
{summaryList[0].content}
+
+
+
+ ) : (
+ // Multiple summaries - card list with modal viewer
+ summaryList.map((item, index) => (
+
setSelectedSummary(item)}
+ />
+ ))
+ )}
+
+
+ {/* Modal Viewer */}
+
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 (
+
+
+
+
+
+ {summary.name}
+
+
+
+
+
+ {preview}{hasMore && '\n...'}
+
+ {hasMore && (
+
+
+ {lines.length} {formatMessage({ id: 'sessionDetail.summary.lines' })}
+
+
+ )}
);
}
+
+// ========================================
+// Exports
+// ========================================
+
+export default SummaryTab;
diff --git a/ccw/frontend/src/pages/session-detail/TaskListTab.tsx b/ccw/frontend/src/pages/session-detail/TaskListTab.tsx
index 7bf96aba..dba56f9f 100644
--- a/ccw/frontend/src/pages/session-detail/TaskListTab.tsx
+++ b/ccw/frontend/src/pages/session-detail/TaskListTab.tsx
@@ -3,51 +3,27 @@
// ========================================
// Tasks tab for session detail page
+import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
ListChecks,
- Loader2,
- Circle,
- CheckCircle,
Code,
} from 'lucide-react';
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 { TaskStatus } from '@/lib/api';
+import { bulkUpdateTaskStatus, updateTaskStatus } from '@/lib/api';
export interface TaskListTabProps {
session: SessionMetadata;
onTaskClick?: (task: TaskData) => void;
}
-// Status configuration
-const taskStatusConfig: Record }> = {
- 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: Circle,
- },
- skipped: {
- label: 'sessionDetail.tasks.status.skipped',
- variant: 'default',
- icon: Circle,
- },
-};
+export interface TaskListTabProps {
+ session: SessionMetadata;
+ onTaskClick?: (task: TaskData) => void;
+}
/**
* 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 inProgress = tasks.filter((t) => t.status === 'in_progress').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(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 (
- {/* Stats Bar */}
-
-
-
- {completed} {formatMessage({ id: 'sessionDetail.tasks.completed' })}
-
-
-
- {inProgress} {formatMessage({ id: 'sessionDetail.tasks.inProgress' })}
-
-
-
- {pending} {formatMessage({ id: 'sessionDetail.tasks.pending' })}
-
- {blocked > 0 && (
-
-
- {blocked} {formatMessage({ id: 'sessionDetail.tasks.blocked' })}
-
- )}
-
+ {/* Stats Bar with Bulk Actions */}
+
{/* Tasks List */}
- {tasks.length === 0 ? (
+ {localTasks.length === 0 ? (
@@ -98,10 +169,7 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
) : (
- {tasks.map((task, index) => {
- const currentStatusConfig = task.status ? taskStatusConfig[task.status] : taskStatusConfig.pending;
- const StatusIcon = currentStatusConfig.icon;
-
+ {localTasks.map((task, index) => {
return (
-
+
{task.task_id}
-
-
- {formatMessage({ id: currentStatusConfig.label })}
-
+ handleTaskStatusChange(task.task_id, newStatus)}
+ size="sm"
+ />
{task.priority && (
-
+
{task.priority}
-
+
)}
diff --git a/ccw/frontend/src/router.tsx b/ccw/frontend/src/router.tsx
index 835eb974..0bce046e 100644
--- a/ccw/frontend/src/router.tsx
+++ b/ccw/frontend/src/router.tsx
@@ -36,6 +36,7 @@ import {
PromptHistoryPage,
ExplorerPage,
GraphExplorerPage,
+ CodexLensManagerPage,
} from '@/pages';
/**
@@ -141,6 +142,10 @@ const routes: RouteObject[] = [
path: 'settings/rules',
element: ,
},
+ {
+ path: 'settings/codexlens',
+ element: ,
+ },
{
path: 'help',
element: ,
@@ -206,6 +211,7 @@ export const ROUTES = {
ENDPOINTS: '/settings/endpoints',
INSTALLATIONS: '/settings/installations',
SETTINGS_RULES: '/settings/rules',
+ CODEXLENS_MANAGER: '/settings/codexlens',
HELP: '/help',
EXPLORER: '/explorer',
GRAPH: '/graph',
diff --git a/ccw/frontend/src/test/i18n.tsx b/ccw/frontend/src/test/i18n.tsx
index c55c90db..ceaf0461 100644
--- a/ccw/frontend/src/test/i18n.tsx
+++ b/ccw/frontend/src/test/i18n.tsx
@@ -111,6 +111,84 @@ const mockMessages: Record> = {
'issues.discovery.status.failed': 'Failed',
'issues.discovery.progress': 'Progress',
'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: {
// Common
@@ -210,6 +288,84 @@ const mockMessages: Record> = {
'issues.discovery.status.failed': 'ๅคฑ่ดฅ',
'issues.discovery.progress': '่ฟๅบฆ',
'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',
},
};
diff --git a/ccw/frontend/tests/e2e/codexlens-manager.spec.ts b/ccw/frontend/tests/e2e/codexlens-manager.spec.ts
new file mode 100644
index 00000000..dcadf575
--- /dev/null
+++ b/ccw/frontend/tests/e2e/codexlens-manager.spec.ts
@@ -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();
+ });
+});
diff --git a/ccw/src/commands/hook.ts b/ccw/src/commands/hook.ts
index 74533d56..fac4ea10 100644
--- a/ccw/src/commands/hook.ts
+++ b/ccw/src/commands/hook.ts
@@ -13,6 +13,7 @@ interface HookOptions {
sessionId?: string;
prompt?: string;
type?: 'session-start' | 'context';
+ path?: string;
}
interface HookData {
@@ -209,6 +210,59 @@ async function sessionContextAction(options: HookOptions): Promise {
}
}
+/**
+ * Parse CCW status.json and output formatted status
+ */
+async function parseStatusAction(options: HookOptions): Promise {
+ 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
*/
@@ -255,15 +309,20 @@ ${chalk.bold('USAGE')}
ccw hook [options]
${chalk.bold('SUBCOMMANDS')}
+ parse-status Parse CCW status.json and display current/next command
session-context Progressive session context loading (replaces curl/bash hook)
notify Send notification to ccw view dashboard
${chalk.bold('OPTIONS')}
--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)
--prompt Current prompt text (alternative to stdin)
${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):')}
ccw hook session-context --stdin
@@ -274,14 +333,14 @@ ${chalk.bold('EXAMPLES')}
ccw hook notify --stdin
${chalk.bold('HOOK CONFIGURATION')}
- ${chalk.gray('Add to .claude/settings.json:')}
+ ${chalk.gray('Add to .claude/settings.json for status tracking:')}
{
"hooks": {
- "UserPromptSubmit": [{
- "hooks": [{
- "type": "command",
- "command": "ccw hook session-context --stdin"
- }]
+ "PostToolUse": [{
+ "trigger": "PostToolUse",
+ "matcher": "Write",
+ "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
): Promise {
switch (subcommand) {
+ case 'parse-status':
+ await parseStatusAction(options);
+ break;
case 'session-context':
case 'context':
await sessionContextAction(options);
diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts
index 6fbac007..8acc9017 100644
--- a/ccw/src/core/routes/cli-routes.ts
+++ b/ccw/src/core/routes/cli-routes.ts
@@ -483,6 +483,35 @@ export async function handleCliRoutes(ctx: RouteContext): Promise {
}
// 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);
if (!conversation) {
res.writeHead(404, { 'Content-Type': 'application/json' });
diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts
index 6f7ac301..2f8bbea4 100644
--- a/ccw/src/core/routes/hooks-routes.ts
+++ b/ccw/src/core/routes/hooks-routes.ts
@@ -412,6 +412,116 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise {
+ 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
if (pathname === '/api/hooks' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path');
@@ -471,3 +581,63 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise}
+ */
+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
+ });
+ });
+ });
+}