# Crucible GUI Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a Tauri v2 desktop app for post-run analysis of crucible experiments — making agent reasoning, metric trends, and code diffs visible.

**Architecture:** Standalone Tauri app reads experiment data directly from the filesystem. Rust backend (Tauri commands) parses JSONL results, reads iteration logs, and accesses git history via `git2`. React + TypeScript frontend renders three pages: project selector, experiment overview, and iteration detail.

**Tech Stack:** Tauri v2, React 18, TypeScript, Tailwind CSS, Recharts, react-diff-viewer-continued, git2 crate, serde

**Spec:** `docs/superpowers/specs/2026-03-18-crucible-gui-design.md`

**Project location:** `~/Documents/Hack/crucible-gui/` (separate repo from crucible)

---

## File Structure

```
crucible-gui/
├── src-tauri/
│   ├── Cargo.toml
│   ├── tauri.conf.json
│   ├── capabilities/default.json
│   └── src/
│       ├── lib.rs              # Tauri command registration + setup
│       ├── main.rs             # Entry point (generated by Tauri)
│       ├── models.rs           # Shared data types (ExperimentRecord, ProjectInfo, etc.)
│       ├── parsers/
│       │   ├── mod.rs
│       │   ├── jsonl.rs        # JSONL parser for results files
│       │   └── config.rs       # config.yaml parser
│       └── commands/
│           ├── mod.rs
│           ├── project.rs      # open_project, list_recent_projects, add_recent_project
│           ├── results.rs      # list_experiments, get_results
│           ├── logs.rs         # get_iteration_log
│           └── git.rs          # get_diff_for_commit
├── src/
│   ├── main.tsx                # React entry point
│   ├── App.tsx                 # Router + layout shell
│   ├── types.ts                # TypeScript types mirroring Rust models
│   ├── hooks/
│   │   └── useTauri.ts         # invoke() wrapper with loading/error state
│   ├── pages/
│   │   ├── Home.tsx            # Project selector
│   │   ├── Overview.tsx        # Experiment overview (chart + table)
│   │   └── Detail.tsx          # Iteration detail (reasoning/diff/log tabs)
│   └── components/
│       ├── Layout.tsx          # Sidebar + content area shell
│       ├── MetricChart.tsx     # Recharts scatter+line chart
│       ├── IterationTable.tsx  # Sortable iteration list
│       ├── DiffViewer.tsx      # Wrapper around react-diff-viewer
│       ├── AgentReasoning.tsx  # Markdown renderer for agent.txt
│       └── RunLog.tsx          # Terminal-style log viewer
├── index.html
├── package.json
├── tsconfig.json
├── tailwind.config.js
├── postcss.config.js
└── vite.config.ts
```

---

## Task 1: Scaffold Tauri v2 + React project

**Files:**
- Create: all scaffold files in `~/Documents/Hack/crucible-gui/`

- [ ] **Step 1: Create Tauri project with React template**

```bash
cd ~/Documents/Hack
npm create tauri-app@latest crucible-gui -- --template react-ts
```

Select: TypeScript, React, npm as package manager.

- [ ] **Step 2: Install frontend dependencies**

```bash
cd ~/Documents/Hack/crucible-gui
npm install
npm install react-router-dom recharts react-markdown react-diff-viewer-continued
npm install -D tailwindcss @tailwindcss/postcss postcss
```

- [ ] **Step 3: Configure Tailwind CSS**

Create `src/app.css`:
```css
@import "tailwindcss";
```

Import it in `src/main.tsx`:
```tsx
import "./app.css";
```

- [ ] **Step 4: Add Rust dependencies to Cargo.toml**

In `src-tauri/Cargo.toml`, add to `[dependencies]`:
```toml
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
git2 = "0.19"
glob = "0.3"
dirs = "6"
```

- [ ] **Step 5: Add filesystem and dialog capabilities**

In `src-tauri/capabilities/default.json`, add permissions:
```json
{
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default"
  ]
}
```

Note: `dialog:default` and `fs:default` will be added in Task 8 when the dialog plugin is installed.

- [ ] **Step 6: Verify build**

```bash
cd ~/Documents/Hack/crucible-gui
npm run tauri dev
```

Expected: Tauri window opens with default React page.

- [ ] **Step 7: Init git and commit**

```bash
cd ~/Documents/Hack/crucible-gui
git init
git add -A
git commit -m "chore: scaffold Tauri v2 + React + TypeScript project"
```

---

## Task 2: Rust data models and JSONL parser

**Files:**
- Create: `src-tauri/src/models.rs`
- Create: `src-tauri/src/parsers/mod.rs`
- Create: `src-tauri/src/parsers/jsonl.rs`
- Modify: `src-tauri/src/lib.rs`

- [ ] **Step 1: Create shared data models**

Create `src-tauri/src/models.rs`:
```rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageInfo {
    pub input_tokens: Option<i64>,
    pub output_tokens: Option<i64>,
    pub estimated_cost_usd: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffStats {
    pub insertions: i64,
    pub deletions: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExperimentRecord {
    pub commit: String,
    pub metric_value: f64,
    pub status: String,
    pub description: String,
    pub iteration: Option<i64>,
    pub timestamp: Option<String>,
    pub delta: Option<f64>,
    pub delta_percent: Option<f64>,
    pub files_changed: Option<Vec<String>>,
    pub diff_stats: Option<DiffStats>,
    pub duration_seconds: Option<f64>,
    pub usage: Option<UsageInfo>,
    pub log_dir: Option<String>,
    pub beam_id: Option<i64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExperimentTag {
    pub tag: String,
    pub record_count: usize,
    pub best_metric: Option<f64>,
    pub first_timestamp: Option<String>,
    pub last_timestamp: Option<String>,
    pub beam_parent: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricConfig {
    pub name: String,
    pub direction: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectInfo {
    pub name: String,
    pub path: String,
    pub metric: MetricConfig,
    pub tags: Vec<String>,
    pub description: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IterationLog {
    pub agent_reasoning: Option<String>,
    pub run_log: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDiff {
    pub path: String,
    pub old_content: String,
    pub new_content: String,
}
```

- [ ] **Step 2: Create JSONL parser**

Create `src-tauri/src/parsers/mod.rs`:
```rust
pub mod jsonl;
pub mod config;
```

Create `src-tauri/src/parsers/jsonl.rs`:
```rust
use crate::models::ExperimentRecord;
use std::path::Path;

pub fn parse_jsonl_file(path: &Path) -> Result<Vec<ExperimentRecord>, String> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
    Ok(parse_jsonl(&content))
}

pub fn parse_jsonl(content: &str) -> Vec<ExperimentRecord> {
    content
        .lines()
        .filter(|line| !line.trim().is_empty())
        .filter_map(|line| serde_json::from_str::<ExperimentRecord>(line).ok())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_valid_jsonl() {
        let content = r#"{"commit":"abc1234","metric_value":42.5,"status":"keep","description":"test"}
{"commit":"def5678","metric_value":38.1,"status":"discard","description":"worse"}"#;
        let records = parse_jsonl(content);
        assert_eq!(records.len(), 2);
        assert_eq!(records[0].status, "keep");
        assert_eq!(records[1].metric_value, 38.1);
    }

    #[test]
    fn test_skip_malformed_lines() {
        let content = r#"{"commit":"abc1234","metric_value":42.5,"status":"keep","description":"ok"}
this is not json
{"commit":"def5678","metric_value":10.0,"status":"crash","description":"boom"}"#;
        let records = parse_jsonl(content);
        assert_eq!(records.len(), 2);
    }

    #[test]
    fn test_empty_content() {
        assert!(parse_jsonl("").is_empty());
        assert!(parse_jsonl("\n\n").is_empty());
    }

    #[test]
    fn test_optional_fields() {
        let content = r#"{"commit":"abc","metric_value":1.0,"status":"keep","description":"d","usage":{"input_tokens":100,"output_tokens":50,"estimated_cost_usd":0.01},"diff_stats":{"insertions":10,"deletions":5}}"#;
        let records = parse_jsonl(content);
        assert_eq!(records.len(), 1);
        let r = &records[0];
        assert_eq!(r.usage.as_ref().unwrap().input_tokens, Some(100));
        assert_eq!(r.diff_stats.as_ref().unwrap().insertions, 10);
    }
}
```

- [ ] **Step 3: Register modules in lib.rs**

In `src-tauri/src/lib.rs`, add:
```rust
mod models;
mod parsers;
mod commands;
```

- [ ] **Step 4: Run tests**

```bash
cd ~/Documents/Hack/crucible-gui/src-tauri
cargo test
```

Expected: All 4 JSONL parser tests pass.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: add data models and JSONL parser with tests"
```

---

## Task 3: Config parser

**Files:**
- Create: `src-tauri/src/parsers/config.rs`

- [ ] **Step 1: Create config.yaml parser**

Create `src-tauri/src/parsers/config.rs`:
```rust
use crate::models::{MetricConfig, ProjectInfo};
use std::path::Path;

pub fn parse_config(project_path: &Path) -> Result<ProjectInfo, String> {
    let config_path = project_path.join(".crucible").join("config.yaml");
    let content = std::fs::read_to_string(&config_path)
        .map_err(|_| format!("找不到 .crucible/config.yaml"))?;
    let raw: serde_yaml::Value = serde_yaml::from_str(&content)
        .map_err(|e| format!("Invalid YAML: {}", e))?;

    let name = raw.get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();

    let description = raw.get("description")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();

    let metric_name = raw.get("metric")
        .and_then(|m| m.get("name"))
        .and_then(|v| v.as_str())
        .unwrap_or("metric")
        .to_string();

    let metric_direction = raw.get("metric")
        .and_then(|m| m.get("direction"))
        .and_then(|v| v.as_str())
        .unwrap_or("maximize")
        .to_string();

    // Discover experiment tags from results-*.jsonl files
    let tags = discover_tags(project_path);

    Ok(ProjectInfo {
        name,
        path: project_path.to_string_lossy().to_string(),
        metric: MetricConfig {
            name: metric_name,
            direction: metric_direction,
        },
        tags,
        description,
    })
}

fn discover_tags(project_path: &Path) -> Vec<String> {
    let pattern = project_path.join("results-*.jsonl");
    let pattern_str = pattern.to_string_lossy();
    glob::glob(&pattern_str)
        .into_iter()
        .flatten()
        .filter_map(|entry| entry.ok())
        .filter_map(|path| {
            let stem = path.file_stem()?.to_str()?;
            // results-{tag}.jsonl → strip "results-" prefix
            stem.strip_prefix("results-").map(|s| s.to_string())
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn setup_project(dir: &Path, config_yaml: &str) {
        let crucible_dir = dir.join(".crucible");
        fs::create_dir_all(&crucible_dir).unwrap();
        fs::write(crucible_dir.join("config.yaml"), config_yaml).unwrap();
    }

    #[test]
    fn test_parse_config() {
        let dir = tempfile::tempdir().unwrap();
        setup_project(dir.path(), r#"
name: optimize-sorting
description: Sort algorithm optimization
metric:
  name: throughput
  direction: maximize
files:
  editable: [sort.py]
commands:
  run: python3 sort.py
  eval: python3 evaluate.py
"#);
        let info = parse_config(dir.path()).unwrap();
        assert_eq!(info.name, "optimize-sorting");
        assert_eq!(info.metric.direction, "maximize");
    }

    #[test]
    fn test_discover_tags() {
        let dir = tempfile::tempdir().unwrap();
        setup_project(dir.path(), "name: test\nmetric:\n  name: m\n  direction: maximize\nfiles:\n  editable: [a.py]\ncommands:\n  run: echo\n  eval: echo");
        fs::write(dir.path().join("results-v1.jsonl"), "").unwrap();
        fs::write(dir.path().join("results-v2.jsonl"), "").unwrap();
        let info = parse_config(dir.path()).unwrap();
        assert!(info.tags.contains(&"v1".to_string()));
        assert!(info.tags.contains(&"v2".to_string()));
    }

    #[test]
    fn test_missing_config() {
        let dir = tempfile::tempdir().unwrap();
        let result = parse_config(dir.path());
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("config.yaml"));
    }
}
```

- [ ] **Step 2: Add tempfile dev-dependency**

In `src-tauri/Cargo.toml`:
```toml
[dev-dependencies]
tempfile = "3"
```

- [ ] **Step 3: Run tests**

```bash
cd ~/Documents/Hack/crucible-gui/src-tauri
cargo test
```

Expected: All config parser tests pass.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: add config.yaml parser with tag discovery"
```

---

## Task 4: Tauri commands — project and results

**Files:**
- Create: `src-tauri/src/commands/mod.rs`
- Create: `src-tauri/src/commands/project.rs`
- Create: `src-tauri/src/commands/results.rs`
- Modify: `src-tauri/src/lib.rs`

- [ ] **Step 1: Create project commands**

Create `src-tauri/src/commands/mod.rs`:
```rust
pub mod project;
pub mod results;
pub mod logs;
pub mod git;
```

Create `src-tauri/src/commands/project.rs`:
```rust
use crate::models::ProjectInfo;
use crate::parsers::config::parse_config;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::State;

pub struct RecentProjects {
    pub items: Mutex<Vec<ProjectInfo>>,
    pub storage_path: PathBuf,
}

impl RecentProjects {
    pub fn load(storage_path: PathBuf) -> Self {
        let items = std::fs::read_to_string(&storage_path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default();
        Self { items: Mutex::new(items), storage_path }
    }

    fn save(&self, projects: &[ProjectInfo]) {
        if let Ok(json) = serde_json::to_string_pretty(projects) {
            let _ = std::fs::write(&self.storage_path, json);
        }
    }
}

#[tauri::command]
pub fn open_project(path: String) -> Result<ProjectInfo, String> {
    parse_config(Path::new(&path))
}

#[tauri::command]
pub fn list_recent_projects(state: State<'_, RecentProjects>) -> Vec<ProjectInfo> {
    state.items.lock().unwrap().clone()
}

#[tauri::command]
pub fn add_recent_project(info: ProjectInfo, state: State<'_, RecentProjects>) {
    let mut projects = state.items.lock().unwrap();
    // Remove duplicate by path, then prepend
    projects.retain(|p| p.path != info.path);
    projects.insert(0, info);
    // Keep max 10
    projects.truncate(10);
    state.save(&projects);
}
```

- [ ] **Step 2: Create results commands**

Create `src-tauri/src/commands/results.rs`:
```rust
use crate::models::{ExperimentRecord, ExperimentTag};
use crate::parsers::jsonl::parse_jsonl_file;
use std::path::Path;

#[tauri::command]
pub fn get_results(path: String, tag: String) -> Result<Vec<ExperimentRecord>, String> {
    let results_path = Path::new(&path).join(format!("results-{}.jsonl", tag));
    parse_jsonl_file(&results_path)
}

#[tauri::command]
pub fn list_experiments(path: String) -> Result<Vec<ExperimentTag>, String> {
    let project_path = Path::new(&path);
    let pattern = project_path.join("results-*.jsonl");
    let pattern_str = pattern.to_string_lossy().to_string();

    let mut tags: Vec<ExperimentTag> = Vec::new();

    for entry in glob::glob(&pattern_str).map_err(|e| e.to_string())?.flatten() {
        let stem = match entry.file_stem().and_then(|s| s.to_str()) {
            Some(s) => s.to_string(),
            None => continue,
        };
        let tag = match stem.strip_prefix("results-") {
            Some(t) => t.to_string(),
            None => continue,
        };

        let records = parse_jsonl_file(&entry).unwrap_or_default();
        let kept: Vec<&ExperimentRecord> = records.iter()
            .filter(|r| r.status == "keep" || r.status == "baseline")
            .collect();
        let best = kept.iter()
            .map(|r| r.metric_value)
            .reduce(f64::max); // Will be adjusted by direction in frontend

        let first_ts = records.first().and_then(|r| r.timestamp.clone());
        let last_ts = records.last().and_then(|r| r.timestamp.clone());

        // Detect beam parent: results-{parent}-beam-{N}.jsonl
        let beam_parent = if tag.contains("-beam-") {
            tag.rsplit_once("-beam-").map(|(parent, _)| parent.to_string())
        } else {
            None
        };

        tags.push(ExperimentTag {
            tag,
            record_count: records.len(),
            best_metric: best,
            first_timestamp: first_ts,
            last_timestamp: last_ts,
            beam_parent,
        });
    }

    Ok(tags)
}
```

- [ ] **Step 3: Register commands in lib.rs**

Replace `src-tauri/src/lib.rs` content:
```rust
mod commands;
mod models;
mod parsers;

use commands::project::RecentProjects;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let app_data = app.path().app_data_dir()
                .expect("failed to get app data dir");
            std::fs::create_dir_all(&app_data).ok();
            let storage_path = app_data.join("recent_projects.json");
            app.manage(RecentProjects::load(storage_path));
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            commands::project::open_project,
            commands::project::list_recent_projects,
            commands::project::add_recent_project,
            commands::results::get_results,
            commands::results::list_experiments,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

- [ ] **Step 4: Build and verify**

```bash
cd ~/Documents/Hack/crucible-gui
npm run tauri dev
```

Expected: Compiles without errors, app window opens.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: add project and results Tauri commands"
```

---

## Task 5: Tauri commands — logs and git diff

**Files:**
- Create: `src-tauri/src/commands/logs.rs`
- Create: `src-tauri/src/commands/git.rs`
- Modify: `src-tauri/src/lib.rs` (register new commands)

- [ ] **Step 1: Create logs command**

Create `src-tauri/src/commands/logs.rs`:
```rust
use crate::models::IterationLog;
use std::path::Path;

/// Reads agent reasoning and run log for a specific iteration.
/// Crucible stores logs at `logs/iter-{N}/` (no tag in path).
#[tauri::command]
pub fn get_iteration_log(path: String, _tag: String, iter: u32) -> IterationLog {
    let base = Path::new(&path).join("logs").join(format!("iter-{}", iter));

    let agent_reasoning = std::fs::read_to_string(base.join("agent.txt")).ok();
    let run_log = std::fs::read_to_string(base.join("run.log")).ok();

    IterationLog {
        agent_reasoning,
        run_log,
    }
}
```

- [ ] **Step 2: Create git diff command**

Create `src-tauri/src/commands/git.rs`:
```rust
use std::path::Path;
use crate::models::FileDiff;
use git2::Repository;

#[tauri::command]
pub fn get_diff_for_commit(path: String, commit_hash: String) -> Result<Vec<FileDiff>, String> {
    let repo = Repository::open(&path).map_err(|e| format!("無法讀取 git 歷史: {}", e))?;
    let obj = repo.revparse_single(&commit_hash)
        .map_err(|e| format!("Commit not found: {}", e))?;
    let commit = obj.peel_to_commit()
        .map_err(|e| format!("Not a commit: {}", e))?;

    let tree = commit.tree().map_err(|e| e.to_string())?;

    // Get parent tree (or empty tree for first commit)
    let parent_tree = if commit.parent_count() > 0 {
        Some(commit.parent(0).map_err(|e| e.to_string())?.tree().map_err(|e| e.to_string())?)
    } else {
        None
    };

    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)
        .map_err(|e| e.to_string())?;

    // Phase 1: collect (path, old_oid, new_oid) tuples from diff deltas
    // to avoid borrow conflict (diff borrows repo, callback would need repo too)
    let mut oid_pairs: Vec<(String, git2::Oid, git2::Oid)> = Vec::new();
    for delta_idx in 0..diff.deltas().len() {
        let delta = diff.get_delta(delta_idx).unwrap();
        let file_path = delta.new_file().path()
            .unwrap_or(Path::new("unknown"))
            .to_string_lossy()
            .to_string();
        oid_pairs.push((file_path, delta.old_file().id(), delta.new_file().id()));
    }

    // Phase 2: resolve blobs from collected OIDs (no borrow conflict)
    let file_diffs: Vec<FileDiff> = oid_pairs.into_iter().map(|(file_path, old_oid, new_oid)| {
        let old_content = if old_oid.is_zero() {
            String::new()
        } else {
            repo.find_blob(old_oid)
                .ok()
                .map(|b| String::from_utf8_lossy(b.content()).to_string())
                .unwrap_or_default()
        };
        let new_content = if new_oid.is_zero() {
            String::new()
        } else {
            repo.find_blob(new_oid)
                .ok()
                .map(|b| String::from_utf8_lossy(b.content()).to_string())
                .unwrap_or_default()
        };
        FileDiff { path: file_path, old_content, new_content }
    }).collect();

    Ok(file_diffs)
}
```

- [ ] **Step 3: Register new commands in lib.rs**

Add to the `invoke_handler` in `src-tauri/src/lib.rs`:
```rust
commands::logs::get_iteration_log,
commands::git::get_diff_for_commit,
```

- [ ] **Step 4: Build and verify**

```bash
cd ~/Documents/Hack/crucible-gui/src-tauri
cargo build
```

Expected: Compiles without errors.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: add logs and git diff Tauri commands"
```

---

## Task 6: TypeScript types and Tauri hook

**Files:**
- Create: `src/types.ts`
- Create: `src/hooks/useTauri.ts`

- [ ] **Step 1: Create TypeScript types mirroring Rust models**

Create `src/types.ts`:
```typescript
export interface UsageInfo {
  input_tokens: number | null;
  output_tokens: number | null;
  estimated_cost_usd: number | null;
}

export interface DiffStats {
  insertions: number;
  deletions: number;
}

export interface ExperimentRecord {
  commit: string;
  metric_value: number;
  status: "keep" | "discard" | "crash" | "baseline";
  description: string;
  iteration: number | null;
  timestamp: string | null;
  delta: number | null;
  delta_percent: number | null;
  files_changed: string[] | null;
  diff_stats: DiffStats | null;
  duration_seconds: number | null;
  usage: UsageInfo | null;
  log_dir: string | null;
  beam_id: number | null;
}

export interface ExperimentTag {
  tag: string;
  record_count: number;
  best_metric: number | null;
  first_timestamp: string | null;
  last_timestamp: string | null;
  beam_parent: string | null;
}

export interface MetricConfig {
  name: string;
  direction: "minimize" | "maximize";
}

export interface ProjectInfo {
  name: string;
  path: string;
  metric: MetricConfig;
  tags: string[];
  description: string;
}

export interface IterationLog {
  agent_reasoning: string | null;
  run_log: string | null;
}

export interface FileDiff {
  path: string;
  old_content: string;
  new_content: string;
}
```

- [ ] **Step 2: Create Tauri invoke wrapper hook**

Create `src/hooks/useTauri.ts`:
```typescript
import { invoke } from "@tauri-apps/api/core";
import { useState, useCallback } from "react";

export function useTauriCommand<T>(command: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const execute = useCallback(async (args?: Record<string, unknown>) => {
    setLoading(true);
    setError(null);
    try {
      const result = await invoke<T>(command, args);
      setData(result);
      return result;
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      setError(msg);
      return null;
    } finally {
      setLoading(false);
    }
  }, [command]);

  return { data, loading, error, execute };
}
```

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "feat: add TypeScript types and useTauri hook"
```

---

## Task 7: App shell — layout and routing

**Files:**
- Modify: `src/App.tsx`
- Modify: `src/main.tsx`
- Create: `src/components/Layout.tsx`
- Create: `src/pages/Home.tsx` (placeholder)
- Create: `src/pages/Overview.tsx` (placeholder)
- Create: `src/pages/Detail.tsx` (placeholder)

- [ ] **Step 1: Create Layout component**

Create `src/components/Layout.tsx`:
```tsx
import { Link, Outlet, useParams } from "react-router-dom";

export default function Layout() {
  const { projectPath } = useParams();

  return (
    <div className="flex h-screen bg-zinc-900 text-zinc-100">
      {/* Sidebar */}
      <nav className="w-56 bg-zinc-950 border-r border-zinc-800 p-4 flex flex-col gap-2">
        <Link to="/" className="text-lg font-bold mb-4 block">Crucible</Link>
        {projectPath && (
          <>
            <Link
              to={`/project/${encodeURIComponent(projectPath)}`}
              className="px-3 py-2 rounded hover:bg-zinc-800 text-sm"
            >
              Overview
            </Link>
          </>
        )}
      </nav>
      {/* Content */}
      <main className="flex-1 overflow-auto">
        <Outlet />
      </main>
    </div>
  );
}
```

- [ ] **Step 2: Create placeholder pages**

Create `src/pages/Home.tsx`:
```tsx
export default function Home() {
  return <div className="p-8"><h1 className="text-2xl font-bold">Welcome to Crucible</h1></div>;
}
```

Create `src/pages/Overview.tsx`:
```tsx
export default function Overview() {
  return <div className="p-8"><h1 className="text-2xl font-bold">Experiment Overview</h1></div>;
}
```

Create `src/pages/Detail.tsx`:
```tsx
export default function Detail() {
  return <div className="p-8"><h1 className="text-2xl font-bold">Iteration Detail</h1></div>;
}
```

- [ ] **Step 3: Set up routing in App.tsx**

Replace `src/App.tsx`:
```tsx
import { HashRouter, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import Home from "./pages/Home";
import Overview from "./pages/Overview";
import Detail from "./pages/Detail";

// Use HashRouter (not BrowserRouter) because Tauri loads via custom protocol
// (tauri://localhost), which doesn't support HTML5 history API routing.
export default function App() {
  return (
    <HashRouter>
      <Routes>
        <Route element={<Layout />}>
          <Route path="/" element={<Home />} />
          <Route path="/project/:projectPath" element={<Overview />} />
          <Route path="/project/:projectPath/iter/:iteration" element={<Detail />} />
        </Route>
      </Routes>
    </HashRouter>
  );
}
```

- [ ] **Step 4: Update main.tsx**

Ensure `src/main.tsx` imports app.css and renders App:
```tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./app.css";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
```

- [ ] **Step 5: Verify**

```bash
npm run tauri dev
```

Expected: Dark themed app with sidebar, "Welcome to Crucible" on home page.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: add app shell with layout, routing, dark theme"
```

---

## Task 8: Home page — project selector

**Files:**
- Modify: `src/pages/Home.tsx`

- [ ] **Step 1: Implement Home page with open dialog and recent projects**

Replace `src/pages/Home.tsx`:
```tsx
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { open } from "@tauri-apps/plugin-dialog";
import { useTauriCommand } from "../hooks/useTauri";
import type { ProjectInfo } from "../types";

export default function Home() {
  const navigate = useNavigate();
  const recentCmd = useTauriCommand<ProjectInfo[]>("list_recent_projects");
  const openCmd = useTauriCommand<ProjectInfo>("open_project");
  const addRecentCmd = useTauriCommand<void>("add_recent_project");

  useEffect(() => {
    recentCmd.execute();
  }, []);

  const handleOpen = async () => {
    const selected = await open({ directory: true });
    if (!selected) return;
    const info = await openCmd.execute({ path: selected });
    if (info) {
      await addRecentCmd.execute({ info });
      navigate(`/project/${encodeURIComponent(info.path)}`);
    }
  };

  const handleClickRecent = async (path: string) => {
    const info = await openCmd.execute({ path });
    if (info) {
      navigate(`/project/${encodeURIComponent(info.path)}`);
    }
  };

  return (
    <div className="p-8 max-w-2xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">Crucible</h1>

      <button
        onClick={handleOpen}
        className="w-full py-4 border-2 border-dashed border-zinc-600 rounded-lg
                   hover:border-blue-500 hover:bg-zinc-800 transition text-zinc-400
                   hover:text-blue-400 mb-8"
      >
        Open Project Directory...
      </button>

      {openCmd.error && (
        <div className="bg-red-900/30 border border-red-700 text-red-300 p-3 rounded mb-4">
          {openCmd.error}
        </div>
      )}

      {recentCmd.data && recentCmd.data.length > 0 && (
        <>
          <h2 className="text-lg font-semibold mb-3 text-zinc-400">Recent Projects</h2>
          <div className="space-y-2">
            {recentCmd.data.map((project) => (
              <button
                key={project.path}
                onClick={() => handleClickRecent(project.path)}
                className="w-full text-left p-4 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition"
              >
                <div className="font-medium">{project.name}</div>
                <div className="text-sm text-zinc-500 mt-1">{project.path}</div>
                {project.tags.length > 0 && (
                  <div className="text-xs text-zinc-600 mt-1">
                    {project.tags.length} experiment{project.tags.length > 1 ? "s" : ""}
                  </div>
                )}
              </button>
            ))}
          </div>
        </>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Install Tauri dialog plugin**

```bash
cd ~/Documents/Hack/crucible-gui
npm install @tauri-apps/plugin-dialog
```

In `src-tauri/Cargo.toml`, add:
```toml
tauri-plugin-dialog = "2"
```

In `src-tauri/src/lib.rs`, add plugin registration:
```rust
tauri::Builder::default()
    .plugin(tauri_plugin_dialog::init())
    .manage(...)
```

Update `src-tauri/capabilities/default.json` to include `"dialog:default"`.

- [ ] **Step 3: Verify**

```bash
npm run tauri dev
```

Expected: Home page with "Open Project Directory..." button. Clicking opens native directory picker. Error toast appears for non-crucible directories.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: implement Home page with project selector"
```

---

## Task 9: Overview page — iteration table

**Files:**
- Modify: `src/pages/Overview.tsx`
- Create: `src/components/IterationTable.tsx`

- [ ] **Step 1: Create IterationTable component**

Create `src/components/IterationTable.tsx`:
```tsx
import type { ExperimentRecord } from "../types";

const STATUS_STYLES: Record<string, { icon: string; color: string }> = {
  keep: { icon: "✓", color: "text-green-400" },
  discard: { icon: "✗", color: "text-zinc-500" },
  crash: { icon: "💥", color: "text-red-400" },
  baseline: { icon: "◆", color: "text-blue-400" },
};

interface Props {
  records: ExperimentRecord[];
  onSelect: (record: ExperimentRecord) => void;
}

export default function IterationTable({ records, onSelect }: Props) {
  return (
    <div className="overflow-auto">
      <table className="w-full text-sm">
        <thead className="text-zinc-400 border-b border-zinc-700">
          <tr>
            <th className="text-left p-2 w-12">#</th>
            <th className="text-left p-2 w-12">Status</th>
            <th className="text-right p-2">Metric</th>
            <th className="text-right p-2">Delta</th>
            <th className="text-right p-2">Cost</th>
            <th className="text-right p-2">Duration</th>
            <th className="text-left p-2">Description</th>
          </tr>
        </thead>
        <tbody>
          {records.map((r, i) => {
            const style = STATUS_STYLES[r.status] ?? STATUS_STYLES.discard;
            return (
              <tr
                key={i}
                onClick={() => onSelect(r)}
                className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer"
              >
                <td className="p-2 text-zinc-500">{r.iteration ?? i + 1}</td>
                <td className={`p-2 ${style.color}`}>{style.icon}</td>
                <td className="p-2 text-right font-mono">{r.metric_value.toFixed(2)}</td>
                <td className="p-2 text-right font-mono text-zinc-400">
                  {r.delta_percent != null ? `${r.delta_percent > 0 ? "+" : ""}${r.delta_percent.toFixed(1)}%` : "—"}
                </td>
                <td className="p-2 text-right font-mono text-zinc-500">
                  {r.usage?.estimated_cost_usd != null ? `$${r.usage.estimated_cost_usd.toFixed(2)}` : "—"}
                </td>
                <td className="p-2 text-right font-mono text-zinc-500">
                  {r.duration_seconds != null ? `${r.duration_seconds.toFixed(0)}s` : "—"}
                </td>
                <td className="p-2 text-zinc-300 truncate max-w-xs">{r.description}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}
```

- [ ] **Step 2: Implement Overview page with tag switcher and table**

Replace `src/pages/Overview.tsx`:
```tsx
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTauriCommand } from "../hooks/useTauri";
import IterationTable from "../components/IterationTable";
import type { ExperimentRecord, ExperimentTag, ProjectInfo } from "../types";

export default function Overview() {
  const { projectPath } = useParams();
  const navigate = useNavigate();
  const path = projectPath ? decodeURIComponent(projectPath) : "";

  const projectCmd = useTauriCommand<ProjectInfo>("open_project");
  const experimentsCmd = useTauriCommand<ExperimentTag[]>("list_experiments");
  const resultsCmd = useTauriCommand<ExperimentRecord[]>("get_results");

  const [selectedTag, setSelectedTag] = useState<string | null>(null);

  useEffect(() => {
    if (!path) return;
    projectCmd.execute({ path });
    experimentsCmd.execute({ path });
  }, [path]);

  useEffect(() => {
    const tags = experimentsCmd.data;
    if (tags && tags.length > 0 && !selectedTag) {
      setSelectedTag(tags[0].tag);
    }
  }, [experimentsCmd.data]);

  useEffect(() => {
    if (selectedTag && path) {
      resultsCmd.execute({ path, tag: selectedTag });
    }
  }, [selectedTag, path]);

  const handleSelectIteration = (record: ExperimentRecord) => {
    if (record.iteration != null) {
      navigate(`/project/${projectPath}/iter/${record.iteration}?tag=${selectedTag}`);
    }
  };

  return (
    <div className="p-6">
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-xl font-bold">{projectCmd.data?.name ?? "Loading..."}</h1>
          <p className="text-sm text-zinc-500">{path}</p>
        </div>
        {/* Tag switcher */}
        {experimentsCmd.data && experimentsCmd.data.length > 1 && (
          <select
            value={selectedTag ?? ""}
            onChange={(e) => setSelectedTag(e.target.value)}
            className="bg-zinc-800 border border-zinc-700 rounded px-3 py-1 text-sm"
          >
            {experimentsCmd.data.map((exp) => (
              <option key={exp.tag} value={exp.tag}>
                {exp.tag} ({exp.record_count} iterations)
              </option>
            ))}
          </select>
        )}
      </div>

      {/* Metric chart placeholder — will be added in Task 10 */}

      {resultsCmd.data && (
        <IterationTable records={resultsCmd.data} onSelect={handleSelectIteration} />
      )}

      {resultsCmd.loading && <p className="text-zinc-500 mt-4">Loading...</p>}
      {resultsCmd.error && (
        <div className="bg-red-900/30 border border-red-700 text-red-300 p-3 rounded mt-4">
          {resultsCmd.error}
        </div>
      )}
    </div>
  );
}
```

- [ ] **Step 3: Verify with a real crucible project**

```bash
npm run tauri dev
```

Open a crucible experiment directory (e.g., `~/Documents/Hack/crucible_projects/optimize-gomoku-v3/`). Expected: Overview page shows iteration table with status icons, metrics, costs.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: implement Overview page with iteration table and tag switcher"
```

---

## Task 10: Overview page — metric chart

**Files:**
- Create: `src/components/MetricChart.tsx`
- Modify: `src/pages/Overview.tsx` (add chart)

- [ ] **Step 1: Create MetricChart component**

Create `src/components/MetricChart.tsx`:
```tsx
import {
  ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip,
  ResponsiveContainer, Line, ComposedChart,
} from "recharts";
import type { ExperimentRecord } from "../types";

interface Props {
  records: ExperimentRecord[];
  direction: "minimize" | "maximize";
}

export default function MetricChart({ records, direction }: Props) {
  // Separate records by status
  const kept = records
    .filter((r) => r.status === "keep" || r.status === "baseline")
    .map((r) => ({ x: r.iteration ?? 0, y: r.metric_value, status: r.status }));

  const discarded = records
    .filter((r) => r.status === "discard")
    .map((r) => ({ x: r.iteration ?? 0, y: r.metric_value, status: r.status }));

  const crashed = records
    .filter((r) => r.status === "crash")
    .map((r) => ({ x: r.iteration ?? 0, status: r.status }));

  // Calculate Y domain excluding crashes
  const nonCrashValues = records
    .filter((r) => r.status !== "crash")
    .map((r) => r.metric_value);

  if (nonCrashValues.length === 0) return <div className="text-zinc-500">No data</div>;

  const yMin = Math.min(...nonCrashValues);
  const yMax = Math.max(...nonCrashValues);
  const padding = (yMax - yMin) * 0.1 || 1;

  return (
    <div className="h-64 w-full">
      <ResponsiveContainer>
        <ComposedChart margin={{ top: 10, right: 20, bottom: 10, left: 20 }}>
          <CartesianGrid strokeDasharray="3 3" stroke="#333" />
          <XAxis
            dataKey="x"
            type="number"
            stroke="#666"
            label={{ value: "Iteration", position: "insideBottom", offset: -5, fill: "#888" }}
          />
          <YAxis
            stroke="#666"
            domain={[yMin - padding, yMax + padding]}
            reversed={direction === "minimize"}
          />
          <Tooltip
            contentStyle={{ backgroundColor: "#1a1a1a", border: "1px solid #333" }}
          />
          {/* Kept values as connected line + dots */}
          <Line
            data={kept}
            dataKey="y"
            stroke="#22c55e"
            dot={{ fill: "#22c55e", r: 4 }}
            connectNulls
            isAnimationActive={false}
          />
          {/* Discarded as gray scatter */}
          <Scatter data={discarded} dataKey="y" fill="#666" opacity={0.5} />
        </ComposedChart>
      </ResponsiveContainer>
      {/* Crash indicators at bottom */}
      {crashed.length > 0 && (
        <div className="flex gap-1 mt-1 px-12">
          {crashed.map((c, i) => (
            <span key={i} className="text-red-400 text-xs" title={`Crash at iter ${c.x}`}>
              💥
            </span>
          ))}
        </div>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Add chart to Overview page**

In `src/pages/Overview.tsx`, add import:
```tsx
import MetricChart from "../components/MetricChart";
```

Replace the chart placeholder comment with:
```tsx
{resultsCmd.data && projectCmd.data && (
  <div className="mb-6 bg-zinc-800/50 rounded-lg p-4">
    <h2 className="text-sm font-medium text-zinc-400 mb-2">
      {projectCmd.data.metric.name} Trend
    </h2>
    <MetricChart
      records={resultsCmd.data}
      direction={projectCmd.data.metric.direction}
    />
  </div>
)}
```

- [ ] **Step 3: Verify with real data**

```bash
npm run tauri dev
```

Open an experiment project. Expected: Chart shows green line for kept iterations, gray dots for discarded, crash icons at bottom. Y-axis oriented correctly for minimize/maximize.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: add metric trend chart to Overview page"
```

---

## Task 11: Detail page — agent reasoning, diff, run log

**Files:**
- Modify: `src/pages/Detail.tsx`
- Create: `src/components/AgentReasoning.tsx`
- Create: `src/components/DiffViewer.tsx`
- Create: `src/components/RunLog.tsx`

- [ ] **Step 1: Create AgentReasoning component**

Create `src/components/AgentReasoning.tsx`:
```tsx
import ReactMarkdown from "react-markdown";

interface Props {
  content: string | null;
}

export default function AgentReasoning({ content }: Props) {
  if (!content) {
    return <div className="text-zinc-500 p-4">此迭代無 agent 日誌</div>;
  }

  return (
    <div className="prose prose-invert prose-sm max-w-none p-4 overflow-auto">
      <ReactMarkdown>{content}</ReactMarkdown>
    </div>
  );
}
```

- [ ] **Step 2: Create DiffViewer component**

Create `src/components/DiffViewer.tsx`:
```tsx
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued";
import type { FileDiff } from "../types";

interface Props {
  diffs: FileDiff[];
}

export default function DiffViewer({ diffs }: Props) {
  if (diffs.length === 0) {
    return <div className="text-zinc-500 p-4">No code changes</div>;
  }

  return (
    <div className="space-y-4 p-4">
      {diffs.map((diff) => (
        <details key={diff.path} open>
          <summary className="cursor-pointer text-sm font-mono text-zinc-300 mb-2 hover:text-white">
            {diff.path}
          </summary>
          <ReactDiffViewer
            oldValue={diff.old_content}
            newValue={diff.new_content}
            splitView={false}
            useDarkTheme
            compareMethod={DiffMethod.LINES}
          />
        </details>
      ))}
    </div>
  );
}
```

- [ ] **Step 3: Create RunLog component**

Create `src/components/RunLog.tsx`:
```tsx
interface Props {
  content: string | null;
}

export default function RunLog({ content }: Props) {
  if (!content) {
    return <div className="text-zinc-500 p-4">此迭代無執行日誌</div>;
  }

  return (
    <pre className="p-4 bg-black text-green-400 font-mono text-xs overflow-auto whitespace-pre-wrap max-h-[70vh]">
      {content}
    </pre>
  );
}
```

- [ ] **Step 4: Implement Detail page with tabs**

Replace `src/pages/Detail.tsx`:
```tsx
import { useEffect, useState } from "react";
import { useParams, useSearchParams, useNavigate } from "react-router-dom";
import { useTauriCommand } from "../hooks/useTauri";
import AgentReasoning from "../components/AgentReasoning";
import DiffViewer from "../components/DiffViewer";
import RunLog from "../components/RunLog";
import type { IterationLog, FileDiff, ExperimentRecord } from "../types";

type Tab = "reasoning" | "diff" | "log";

export default function Detail() {
  const { projectPath, iteration } = useParams();
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const path = projectPath ? decodeURIComponent(projectPath) : "";
  const tag = searchParams.get("tag") ?? "";
  const iter = Number(iteration);

  const [activeTab, setActiveTab] = useState<Tab>("reasoning");

  const logCmd = useTauriCommand<IterationLog>("get_iteration_log");
  const diffCmd = useTauriCommand<FileDiff[]>("get_diff_for_commit");
  const resultsCmd = useTauriCommand<ExperimentRecord[]>("get_results");

  // Current iteration record (for summary card)
  const record = resultsCmd.data?.find((r) => r.iteration === iter);

  useEffect(() => {
    if (!path || !tag) return;
    logCmd.execute({ path, tag, iter });
    resultsCmd.execute({ path, tag });
  }, [path, tag, iter]);

  // Fetch diff when commit is known and diff tab selected
  useEffect(() => {
    if (activeTab === "diff" && record?.commit && path) {
      diffCmd.execute({ path, commitHash: record.commit });
    }
  }, [activeTab, record?.commit, path]);

  const tabs: { key: Tab; label: string }[] = [
    { key: "reasoning", label: "Agent Reasoning" },
    { key: "diff", label: "Code Diff" },
    { key: "log", label: "Run Log" },
  ];

  return (
    <div className="p-6">
      {/* Back button */}
      <button
        onClick={() => navigate(-1)}
        className="text-zinc-400 hover:text-white text-sm mb-4"
      >
        &larr; Back to Overview
      </button>

      <h1 className="text-xl font-bold mb-4">Iteration {iter}</h1>

      {/* Summary card */}
      {record && (
        <div className="grid grid-cols-5 gap-4 mb-6 bg-zinc-800 rounded-lg p-4">
          <div>
            <div className="text-xs text-zinc-500">Metric</div>
            <div className="font-mono font-bold">{record.metric_value.toFixed(2)}</div>
          </div>
          <div>
            <div className="text-xs text-zinc-500">Delta</div>
            <div className="font-mono">
              {record.delta_percent != null ? `${record.delta_percent > 0 ? "+" : ""}${record.delta_percent.toFixed(1)}%` : "—"}
            </div>
          </div>
          <div>
            <div className="text-xs text-zinc-500">Duration</div>
            <div className="font-mono">{record.duration_seconds != null ? `${record.duration_seconds.toFixed(0)}s` : "—"}</div>
          </div>
          <div>
            <div className="text-xs text-zinc-500">Files</div>
            <div className="font-mono">{record.files_changed?.length ?? 0}</div>
          </div>
          <div>
            <div className="text-xs text-zinc-500">Cost</div>
            <div className="font-mono">{record.usage?.estimated_cost_usd != null ? `$${record.usage.estimated_cost_usd.toFixed(2)}` : "—"}</div>
          </div>
        </div>
      )}

      {/* Tabs */}
      <div className="flex border-b border-zinc-700 mb-4">
        {tabs.map((tab) => (
          <button
            key={tab.key}
            onClick={() => setActiveTab(tab.key)}
            className={`px-4 py-2 text-sm border-b-2 transition ${
              activeTab === tab.key
                ? "border-blue-500 text-blue-400"
                : "border-transparent text-zinc-400 hover:text-zinc-200"
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {/* Tab content */}
      {activeTab === "reasoning" && (
        <AgentReasoning content={logCmd.data?.agent_reasoning ?? null} />
      )}
      {activeTab === "diff" && (
        <DiffViewer diffs={diffCmd.data ?? []} />
      )}
      {activeTab === "log" && (
        <RunLog content={logCmd.data?.run_log ?? null} />
      )}
    </div>
  );
}
```

- [ ] **Step 5: Install @tailwindcss/typography for prose styles**

```bash
npm install @tailwindcss/typography
```

In `src/app.css`, add the plugin import (Tailwind v4 style):
```css
@import "tailwindcss";
@plugin "@tailwindcss/typography";
```

- [ ] **Step 6: Verify end-to-end**

```bash
npm run tauri dev
```

Open a crucible project → click an iteration → verify all 3 tabs work:
1. Agent Reasoning: markdown-rendered agent.txt
2. Code Diff: syntax-highlighted side-by-side (or unified) diff
3. Run Log: terminal-style monospace output

- [ ] **Step 7: Commit**

```bash
git add -A
git commit -m "feat: implement Detail page with reasoning, diff, and log tabs"
```

---

## Task 12: Polish and integration test

**Files:**
- Modify: `src/components/Layout.tsx` (enhance sidebar)
- Various minor fixes

- [ ] **Step 1: Enhance sidebar with active project info**

Update `src/components/Layout.tsx` to show the current project name and metric info in the sidebar when a project is open.

- [ ] **Step 2: Manual integration test checklist**

Test with a real crucible experiment project:

1. Open app → Home page shows
2. Click "Open Project Directory..." → select a crucible project → navigates to Overview
3. Overview shows metric chart + iteration table
4. Tag switcher works (if multiple tags exist)
5. Click iteration → Detail page loads
6. Agent Reasoning tab shows markdown content
7. Code Diff tab shows syntax-highlighted diff
8. Run Log tab shows terminal output
9. Back button returns to Overview
10. Open a non-crucible directory → error toast appears

- [ ] **Step 3: Fix any issues found**

- [ ] **Step 4: Final commit**

```bash
git add -A
git commit -m "feat: polish UI and complete MVP integration"
```

---

## Verification Checklist

After all tasks are complete, verify:

- [ ] `npm run tauri dev` launches without errors
- [ ] `cargo test` passes all Rust tests (JSONL parser, config parser)
- [ ] Can open a real crucible experiment directory
- [ ] Metric chart renders correctly (green=keep, gray=discard, crash excluded from Y-axis)
- [ ] Iteration table shows all records with correct status icons
- [ ] Agent reasoning renders as markdown
- [ ] Code diff shows syntax-highlighted changes
- [ ] Run log displays in terminal style
- [ ] Error handling works (non-crucible dir, missing logs)
