orchai/docs/superpowers/plans/2026-04-13-orchai-phase1-foundation.md
thibaud-leclere d2ba241c76 docs: mark Phase 1 and Phase 2 plan tasks as completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:02:04 +02:00

44 KiB

Orchai Phase 1: Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [x]) syntax for tracking.

Goal: Get a working Tauri 2 desktop app with SQLite storage and full Project CRUD (create from local path or clone URL, list, edit, delete) with a React UI.

Architecture: Tauri 2 (Rust backend) with React + TypeScript frontend. SQLite database via rusqlite for persistence. Tauri IPC commands expose backend operations to the frontend. React Router for navigation with a sidebar-based layout.

Tech Stack: Rust, Tauri 2, React 18, TypeScript, Vite, Tailwind CSS, SQLite (rusqlite), react-router-dom v6


Phasing Strategy

This is Plan 1 of 4:

  • Plan 1 (this): Foundation -- Tauri scaffold, SQLite, Project Manager
  • Plan 2: Tuleap Integration -- credentials, API client, poller, filter engine, tracker config
  • Plan 3: Agent Pipeline -- orchestrator, worktree manager, ticket processing, results UI
  • Plan 4: Notifications + Polish -- notifier, system notifications, dashboard

File Structure

orchai/
├── docs/                              # existing
├── src-tauri/
│   ├── Cargo.toml                     # modify: add rusqlite, uuid, chrono, serde
│   ├── build.rs                       # from scaffold
│   ├── tauri.conf.json                # modify: app name, identifier, permissions
│   ├── capabilities/
│   │   └── default.json               # modify: add dialog permissions
│   ├── migrations/
│   │   └── 001_init.sql               # create: full schema (all tables)
│   └── src/
│       ├── main.rs                    # from scaffold (unchanged)
│       ├── lib.rs                     # modify: setup app state, register commands
│       ├── db.rs                      # create: SQLite init + migration runner
│       ├── error.rs                   # create: shared error type
│       ├── models/
│       │   ├── mod.rs                 # create: re-exports
│       │   └── project.rs             # create: Project struct + CRUD
│       └── commands/
│           ├── mod.rs                 # create: re-exports
│           └── project.rs             # create: Tauri commands for project CRUD
├── src/
│   ├── main.tsx                       # from scaffold (unchanged)
│   ├── App.tsx                        # modify: router setup
│   ├── App.css                        # delete (replaced by Tailwind)
│   ├── index.css                      # modify: Tailwind directives
│   ├── lib/
│   │   ├── types.ts                   # create: TypeScript types matching Rust models
│   │   └── api.ts                     # create: typed invoke wrappers
│   ├── components/
│   │   ├── layout/
│   │   │   ├── AppLayout.tsx          # create: sidebar + main content area
│   │   │   └── Sidebar.tsx            # create: project list + add button
│   │   └── projects/
│   │       ├── ProjectList.tsx        # create: empty state / project cards
│   │       ├── ProjectForm.tsx        # create: create/edit form with folder picker
│   │       └── ProjectDashboard.tsx   # create: project overview (placeholder)
├── index.html                         # from scaffold
├── package.json                       # modify: add dependencies
├── vite.config.ts                     # from scaffold
├── tsconfig.json                      # from scaffold
├── tsconfig.node.json                 # from scaffold
├── tailwind.config.js                 # create
├── postcss.config.js                  # create
└── .gitignore                         # from scaffold

Task 1: Scaffold Tauri 2 + React + TypeScript

Files:

  • Create: entire project scaffold via CLI

  • Preserve: docs/, .git/

  • Step 1: Save existing repo contents

cd /home/leclere/Projets
cp -r orchai/docs /tmp/orchai-docs-backup
cp -r orchai/.git /tmp/orchai-git-backup
  • Step 2: Scaffold Tauri 2 project
cd /home/leclere/Projets
rm -rf orchai
npm create tauri-app@latest orchai

When prompted, select:

  • Project name: orchai

  • Identifier: com.orchai.app

  • Frontend language: TypeScript / JavaScript

  • Package manager: npm

  • UI template: React

  • UI flavor: TypeScript

  • Step 3: Restore repo history and docs

cd /home/leclere/Projets/orchai
rm -rf .git
cp -r /tmp/orchai-git-backup .git
cp -r /tmp/orchai-docs-backup docs
rm -rf /tmp/orchai-docs-backup /tmp/orchai-git-backup
  • Step 4: Install dependencies and verify
cd /home/leclere/Projets/orchai
npm install
cd src-tauri && cargo build
cd ..

Expected: build succeeds with no errors.

  • Step 5: Verify dev server starts
npm run tauri dev

Expected: Tauri window opens with the default React starter page. Close it after verifying.

  • Step 6: Commit
git add -A
git commit -m "scaffold: Tauri 2 + React + TypeScript via create-tauri-app"

Task 2: Configure Tailwind CSS + project metadata

Files:

  • Create: tailwind.config.js, postcss.config.js

  • Modify: src/index.css, package.json, src-tauri/tauri.conf.json

  • Delete: src/App.css

  • Step 1: Install Tailwind

cd /home/leclere/Projets/orchai
npm install -D tailwindcss @tailwindcss/vite
  • Step 2: Add Tailwind to Vite config

Replace the contents of vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

const host = process.env.TAURI_DEV_HOST;

export default defineConfig(async () => ({
  plugins: [react(), tailwindcss()],
  clearScreen: false,
  server: {
    port: 1420,
    strictPort: true,
    host: host || false,
    hmr: host
      ? {
          protocol: "ws",
          host,
          port: 1421,
        }
      : undefined,
    watch: {
      ignored: ["**/src-tauri/**"],
    },
  },
}));
  • Step 3: Replace index.css with Tailwind directives

Replace the contents of src/index.css:

@import "tailwindcss";
  • Step 4: Delete App.css and clean up App.tsx

Delete src/App.css.

Replace src/App.tsx:

function App() {
  return (
    <div className="min-h-screen bg-gray-50 text-gray-900">
      <h1 className="text-2xl font-bold p-8">Orchai</h1>
    </div>
  );
}

export default App;
  • Step 5: Update Tauri config

In src-tauri/tauri.conf.json, update the app section:

{
  "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
  "productName": "Orchai",
  "version": "0.1.0",
  "identifier": "com.orchai.app",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "app": {
    "title": "Orchai",
    "windows": [
      {
        "title": "Orchai",
        "width": 1200,
        "height": 800
      }
    ],
    "security": {
      "csp": null
    }
  }
}
  • Step 6: Verify Tailwind works
npm run tauri dev

Expected: window opens, "Orchai" heading rendered with Tailwind styling (bold, large). Close after verifying.

  • Step 7: Commit
git add -A
git commit -m "configure: Tailwind CSS + app metadata"

Task 3: SQLite database + migration system

Files:

  • Modify: src-tauri/Cargo.toml

  • Create: src-tauri/migrations/001_init.sql

  • Create: src-tauri/src/db.rs

  • Create: src-tauri/src/error.rs

  • Modify: src-tauri/src/lib.rs

  • Step 1: Write the failing test for db initialization

Add dependencies to src-tauri/Cargo.toml under [dependencies]:

rusqlite = { version = "0.31", features = ["bundled"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }

Create src-tauri/src/db.rs:

use rusqlite::{Connection, Result};
use std::path::Path;

pub fn init(db_path: &Path) -> Result<Connection> {
    todo!()
}

pub fn init_in_memory() -> Result<Connection> {
    todo!()
}

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

    #[test]
    fn test_init_in_memory_creates_tables() {
        let conn = init_in_memory().expect("should initialize");

        // Verify all 6 tables exist
        let tables: Vec<String> = conn
            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
            .unwrap()
            .query_map([], |row| row.get(0))
            .unwrap()
            .collect::<Result<Vec<String>, _>>()
            .unwrap();

        assert_eq!(
            tables,
            vec![
                "notifications",
                "processed_tickets",
                "projects",
                "tuleap_credentials",
                "watched_trackers",
                "worktrees",
            ]
        );
    }

    #[test]
    fn test_init_in_memory_enables_foreign_keys() {
        let conn = init_in_memory().expect("should initialize");
        let fk_enabled: i32 = conn
            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
            .unwrap();
        assert_eq!(fk_enabled, 1);
    }

    #[test]
    fn test_init_in_memory_sets_wal_mode() {
        let conn = init_in_memory().expect("should initialize");
        let mode: String = conn
            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
            .unwrap();
        assert_eq!(mode, "wal");
    }
}
  • Step 2: Run tests to verify they fail
cd /home/leclere/Projets/orchai/src-tauri
cargo test db::tests

Expected: 3 failures with not yet implemented.

  • Step 3: Create migration SQL

Create src-tauri/migrations/001_init.sql:

CREATE TABLE IF NOT EXISTS projects (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    path TEXT NOT NULL,
    cloned_from TEXT,
    base_branch TEXT NOT NULL DEFAULT 'main',
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS tuleap_credentials (
    id TEXT PRIMARY KEY,
    tuleap_url TEXT NOT NULL,
    username TEXT NOT NULL,
    password_encrypted TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS watched_trackers (
    id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    tracker_id INTEGER NOT NULL,
    tracker_label TEXT NOT NULL,
    polling_interval INTEGER NOT NULL DEFAULT 10,
    agent_config_json TEXT NOT NULL,
    filters_json TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS processed_tickets (
    id TEXT PRIMARY KEY,
    tracker_id TEXT NOT NULL REFERENCES watched_trackers(id) ON DELETE CASCADE,
    artifact_id INTEGER NOT NULL,
    artifact_title TEXT NOT NULL,
    artifact_data TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'Pending',
    analyst_report TEXT,
    developer_report TEXT,
    detected_at TEXT NOT NULL DEFAULT (datetime('now')),
    processed_at TEXT
);

CREATE TABLE IF NOT EXISTS worktrees (
    id TEXT PRIMARY KEY,
    ticket_id TEXT NOT NULL REFERENCES processed_tickets(id),
    path TEXT NOT NULL,
    branch_name TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'Active',
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    merged_at TEXT,
    merged_into TEXT
);

CREATE TABLE IF NOT EXISTS notifications (
    id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    ticket_id TEXT REFERENCES processed_tickets(id),
    type TEXT NOT NULL,
    title TEXT NOT NULL,
    message TEXT NOT NULL,
    read INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
  • Step 4: Implement db::init and db::init_in_memory

Replace the todo!() implementations in src-tauri/src/db.rs:

use rusqlite::{Connection, Result};
use std::path::Path;

const MIGRATION_001: &str = include_str!("../migrations/001_init.sql");

pub fn init(db_path: &Path) -> Result<Connection> {
    let conn = Connection::open(db_path)?;
    configure(&conn)?;
    migrate(&conn)?;
    Ok(conn)
}

pub fn init_in_memory() -> Result<Connection> {
    let conn = Connection::open_in_memory()?;
    configure(&conn)?;
    migrate(&conn)?;
    Ok(conn)
}

fn configure(conn: &Connection) -> Result<()> {
    conn.pragma_update(None, "journal_mode", "wal")?;
    conn.pragma_update(None, "foreign_keys", "ON")?;
    Ok(())
}

fn migrate(conn: &Connection) -> Result<()> {
    let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;

    if version < 1 {
        conn.execute_batch(MIGRATION_001)?;
        conn.pragma_update(None, "user_version", 1)?;
    }

    Ok(())
}
  • Step 5: Run tests to verify they pass
cd /home/leclere/Projets/orchai/src-tauri
cargo test db::tests

Expected: 3 tests pass. Note: test_init_in_memory_sets_wal_mode will return "memory" for in-memory DBs. Update the test:

Replace the WAL test assertion:

    #[test]
    fn test_init_in_memory_sets_wal_mode() {
        // WAL is set but in-memory DBs report "memory" — verify no error on configure
        let conn = init_in_memory().expect("should initialize");
        let mode: String = conn
            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
            .unwrap();
        // In-memory databases report "memory" instead of "wal"
        assert_eq!(mode, "memory");
    }

Re-run tests. Expected: 3 pass.

  • Step 6: Create error type

Create src-tauri/src/error.rs:

use serde::Serialize;

#[derive(Debug, Serialize)]
pub struct AppError {
    pub message: String,
}

impl From<rusqlite::Error> for AppError {
    fn from(e: rusqlite::Error) -> Self {
        AppError {
            message: e.to_string(),
        }
    }
}

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError {
            message: e.to_string(),
        }
    }
}

impl From<String> for AppError {
    fn from(s: String) -> Self {
        AppError { message: s }
    }
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}
  • Step 7: Wire up db module in lib.rs

Replace src-tauri/src/lib.rs:

mod db;
mod error;

use std::sync::Mutex;

pub struct AppState {
    pub db: Mutex<rusqlite::Connection>,
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let db_dir = app.path().app_data_dir()?;
            std::fs::create_dir_all(&db_dir)?;
            let db_path = db_dir.join("orchai.db");
            let conn = db::init(&db_path).expect("Failed to initialize database");
            app.manage(AppState {
                db: Mutex::new(conn),
            });
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  • Step 8: Verify it compiles and runs
cd /home/leclere/Projets/orchai/src-tauri
cargo build

Expected: compiles with no errors.

  • Step 9: Commit
git add -A
git commit -m "feat: SQLite database with migration system and full schema"

Task 4: Project model + CRUD repository + tests

Files:

  • Create: src-tauri/src/models/mod.rs

  • Create: src-tauri/src/models/project.rs

  • Modify: src-tauri/src/lib.rs (add mod models)

  • Step 1: Write failing tests for Project CRUD

Create src-tauri/src/models/mod.rs:

pub mod project;

Create src-tauri/src/models/project.rs:

use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
    pub id: String,
    pub name: String,
    pub path: String,
    pub cloned_from: Option<String>,
    pub base_branch: String,
    pub created_at: String,
}

impl Project {
    pub fn insert(
        conn: &Connection,
        name: &str,
        path: &str,
        cloned_from: Option<&str>,
        base_branch: &str,
    ) -> Result<Project> {
        todo!()
    }

    pub fn list(conn: &Connection) -> Result<Vec<Project>> {
        todo!()
    }

    pub fn get_by_id(conn: &Connection, id: &str) -> Result<Project> {
        todo!()
    }

    pub fn update(conn: &Connection, id: &str, name: &str, base_branch: &str) -> Result<()> {
        todo!()
    }

    pub fn delete(conn: &Connection, id: &str) -> Result<()> {
        todo!()
    }
}

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

    fn setup() -> Connection {
        db::init_in_memory().expect("db init should succeed")
    }

    #[test]
    fn test_insert_project_local_path() {
        let conn = setup();
        let project = Project::insert(&conn, "My Project", "/home/user/code/myproject", None, "main")
            .expect("insert should succeed");

        assert_eq!(project.name, "My Project");
        assert_eq!(project.path, "/home/user/code/myproject");
        assert!(project.cloned_from.is_none());
        assert_eq!(project.base_branch, "main");
        assert!(!project.id.is_empty());
        assert!(!project.created_at.is_empty());
    }

    #[test]
    fn test_insert_project_cloned() {
        let conn = setup();
        let project = Project::insert(
            &conn,
            "Cloned Project",
            "/home/user/code/cloned",
            Some("https://github.com/org/repo.git"),
            "stable",
        )
        .expect("insert should succeed");

        assert_eq!(project.cloned_from.as_deref(), Some("https://github.com/org/repo.git"));
        assert_eq!(project.base_branch, "stable");
    }

    #[test]
    fn test_list_projects_empty() {
        let conn = setup();
        let projects = Project::list(&conn).expect("list should succeed");
        assert!(projects.is_empty());
    }

    #[test]
    fn test_list_projects_returns_all() {
        let conn = setup();
        Project::insert(&conn, "A", "/path/a", None, "main").unwrap();
        Project::insert(&conn, "B", "/path/b", None, "main").unwrap();

        let projects = Project::list(&conn).expect("list should succeed");
        assert_eq!(projects.len(), 2);
    }

    #[test]
    fn test_get_by_id() {
        let conn = setup();
        let created = Project::insert(&conn, "Test", "/path/test", None, "main").unwrap();
        let found = Project::get_by_id(&conn, &created.id).expect("get should succeed");

        assert_eq!(found.id, created.id);
        assert_eq!(found.name, "Test");
    }

    #[test]
    fn test_get_by_id_not_found() {
        let conn = setup();
        let result = Project::get_by_id(&conn, "nonexistent");
        assert!(result.is_err());
    }

    #[test]
    fn test_update_project() {
        let conn = setup();
        let created = Project::insert(&conn, "Old Name", "/path", None, "main").unwrap();

        Project::update(&conn, &created.id, "New Name", "develop").expect("update should succeed");

        let updated = Project::get_by_id(&conn, &created.id).unwrap();
        assert_eq!(updated.name, "New Name");
        assert_eq!(updated.base_branch, "develop");
    }

    #[test]
    fn test_delete_project() {
        let conn = setup();
        let created = Project::insert(&conn, "ToDelete", "/path", None, "main").unwrap();

        Project::delete(&conn, &created.id).expect("delete should succeed");

        let result = Project::get_by_id(&conn, &created.id);
        assert!(result.is_err());
    }
}

Add mod models; to src-tauri/src/lib.rs (after mod error;).

  • Step 2: Run tests to verify they fail
cd /home/leclere/Projets/orchai/src-tauri
cargo test models::project::tests

Expected: 8 failures with not yet implemented.

  • Step 3: Implement Project CRUD

Replace the todo!() implementations in src-tauri/src/models/project.rs:

    pub fn insert(
        conn: &Connection,
        name: &str,
        path: &str,
        cloned_from: Option<&str>,
        base_branch: &str,
    ) -> Result<Project> {
        let id = Uuid::new_v4().to_string();
        let now = chrono::Utc::now().to_rfc3339();

        conn.execute(
            "INSERT INTO projects (id, name, path, cloned_from, base_branch, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
            params![id, name, path, cloned_from, base_branch, now],
        )?;

        Ok(Project {
            id,
            name: name.to_string(),
            path: path.to_string(),
            cloned_from: cloned_from.map(String::from),
            base_branch: base_branch.to_string(),
            created_at: now,
        })
    }

    pub fn list(conn: &Connection) -> Result<Vec<Project>> {
        let mut stmt = conn.prepare(
            "SELECT id, name, path, cloned_from, base_branch, created_at FROM projects ORDER BY created_at DESC",
        )?;
        let rows = stmt.query_map([], |row| {
            Ok(Project {
                id: row.get(0)?,
                name: row.get(1)?,
                path: row.get(2)?,
                cloned_from: row.get(3)?,
                base_branch: row.get(4)?,
                created_at: row.get(5)?,
            })
        })?;
        rows.collect()
    }

    pub fn get_by_id(conn: &Connection, id: &str) -> Result<Project> {
        conn.query_row(
            "SELECT id, name, path, cloned_from, base_branch, created_at FROM projects WHERE id = ?1",
            params![id],
            |row| {
                Ok(Project {
                    id: row.get(0)?,
                    name: row.get(1)?,
                    path: row.get(2)?,
                    cloned_from: row.get(3)?,
                    base_branch: row.get(4)?,
                    created_at: row.get(5)?,
                })
            },
        )
    }

    pub fn update(conn: &Connection, id: &str, name: &str, base_branch: &str) -> Result<()> {
        conn.execute(
            "UPDATE projects SET name = ?1, base_branch = ?2 WHERE id = ?3",
            params![name, base_branch, id],
        )?;
        Ok(())
    }

    pub fn delete(conn: &Connection, id: &str) -> Result<()> {
        conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
        Ok(())
    }

Add the missing imports at the top of the file:

use chrono;
  • Step 4: Run tests to verify they pass
cd /home/leclere/Projets/orchai/src-tauri
cargo test models::project::tests

Expected: 8 tests pass.

  • Step 5: Commit
git add -A
git commit -m "feat: Project model with CRUD operations and tests"

Task 5: Project Tauri commands

Files:

  • Create: src-tauri/src/commands/mod.rs

  • Create: src-tauri/src/commands/project.rs

  • Modify: src-tauri/src/lib.rs

  • Modify: src-tauri/Cargo.toml (add tauri-plugin-dialog)

  • Step 1: Add dialog plugin dependency

Add to src-tauri/Cargo.toml under [dependencies]:

tauri-plugin-dialog = "2"

Add to the capabilities/default.json permissions array:

"dialog:default"
  • Step 2: Create commands module

Create src-tauri/src/commands/mod.rs:

pub mod project;

Create src-tauri/src/commands/project.rs:

use crate::error::AppError;
use crate::models::project::Project;
use crate::AppState;
use std::process::Command;
use tauri::State;

#[tauri::command]
pub fn create_project(
    state: State<'_, AppState>,
    name: String,
    path_or_url: String,
    base_branch: String,
) -> Result<Project, AppError> {
    let is_url = path_or_url.starts_with("http://")
        || path_or_url.starts_with("https://")
        || path_or_url.starts_with("git@");

    let (local_path, cloned_from) = if is_url {
        let home = dirs::home_dir().ok_or_else(|| AppError::from("Cannot determine home directory".to_string()))?;
        let clone_dir = home.join("orchai-repos").join(&name);
        std::fs::create_dir_all(&clone_dir)?;

        let output = Command::new("git")
            .args(["clone", &path_or_url, clone_dir.to_str().unwrap()])
            .output()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(AppError::from(format!("git clone failed: {}", stderr)));
        }

        (clone_dir.to_string_lossy().to_string(), Some(path_or_url))
    } else {
        let path = std::path::Path::new(&path_or_url);
        if !path.exists() {
            return Err(AppError::from(format!("Path does not exist: {}", path_or_url)));
        }
        if !path.join(".git").exists() {
            return Err(AppError::from(format!("Not a git repository: {}", path_or_url)));
        }
        (path_or_url, None)
    };

    let db = state.db.lock().unwrap();
    let project = Project::insert(&db, &name, &local_path, cloned_from.as_deref(), &base_branch)?;
    Ok(project)
}

#[tauri::command]
pub fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, AppError> {
    let db = state.db.lock().unwrap();
    let projects = Project::list(&db)?;
    Ok(projects)
}

#[tauri::command]
pub fn get_project(state: State<'_, AppState>, id: String) -> Result<Project, AppError> {
    let db = state.db.lock().unwrap();
    let project = Project::get_by_id(&db, &id)?;
    Ok(project)
}

#[tauri::command]
pub fn update_project(
    state: State<'_, AppState>,
    id: String,
    name: String,
    base_branch: String,
) -> Result<(), AppError> {
    let db = state.db.lock().unwrap();
    Project::update(&db, &id, &name, &base_branch)?;
    Ok(())
}

#[tauri::command]
pub fn delete_project(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
    let db = state.db.lock().unwrap();
    Project::delete(&db, &id)?;
    Ok(())
}
  • Step 3: Add dirs dependency

Add to src-tauri/Cargo.toml under [dependencies]:

dirs = "5"
  • Step 4: Wire up commands in lib.rs

Replace src-tauri/src/lib.rs:

mod commands;
mod db;
mod error;
mod models;

use std::sync::Mutex;

pub struct AppState {
    pub db: Mutex<rusqlite::Connection>,
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .setup(|app| {
            let db_dir = app.path().app_data_dir()?;
            std::fs::create_dir_all(&db_dir)?;
            let db_path = db_dir.join("orchai.db");
            let conn = db::init(&db_path).expect("Failed to initialize database");
            app.manage(AppState {
                db: Mutex::new(conn),
            });
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            commands::project::create_project,
            commands::project::list_projects,
            commands::project::get_project,
            commands::project::update_project,
            commands::project::delete_project,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  • Step 5: Verify it compiles
cd /home/leclere/Projets/orchai/src-tauri
cargo build

Expected: compiles with no errors.

  • Step 6: Commit
git add -A
git commit -m "feat: Tauri commands for project CRUD with git clone support"

Task 6: TypeScript types + Tauri API wrappers

Files:

  • Create: src/lib/types.ts

  • Create: src/lib/api.ts

  • Step 1: Install frontend dependencies

cd /home/leclere/Projets/orchai
npm install react-router-dom
npm install @tauri-apps/plugin-dialog
  • Step 2: Create TypeScript types

Create src/lib/types.ts:

export interface Project {
  id: string;
  name: string;
  path: string;
  cloned_from: string | null;
  base_branch: string;
  created_at: string;
}
  • Step 3: Create API wrapper

Create src/lib/api.ts:

import { invoke } from "@tauri-apps/api/core";
import type { Project } from "./types";

export async function createProject(
  name: string,
  pathOrUrl: string,
  baseBranch: string
): Promise<Project> {
  return invoke("create_project", {
    name,
    pathOrUrl,
    baseBranch,
  });
}

export async function listProjects(): Promise<Project[]> {
  return invoke("list_projects");
}

export async function getProject(id: string): Promise<Project> {
  return invoke("get_project", { id });
}

export async function updateProject(
  id: string,
  name: string,
  baseBranch: string
): Promise<void> {
  return invoke("update_project", { id, name, baseBranch });
}

export async function deleteProject(id: string): Promise<void> {
  return invoke("delete_project", { id });
}
  • Step 4: Commit
git add -A
git commit -m "feat: TypeScript types and Tauri API wrappers for project CRUD"

Task 7: React app shell with router + layout

Files:

  • Modify: src/App.tsx

  • Create: src/components/layout/AppLayout.tsx

  • Create: src/components/layout/Sidebar.tsx

  • Step 1: Create Sidebar component

Create directory structure:

mkdir -p src/components/layout src/components/projects

Create src/components/layout/Sidebar.tsx:

import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { listProjects } from "../../lib/api";
import type { Project } from "../../lib/types";

export default function Sidebar() {
  const [projects, setProjects] = useState<Project[]>([]);
  const { projectId } = useParams();

  useEffect(() => {
    listProjects().then(setProjects);
  }, []);

  // Expose a refresh function via custom event
  useEffect(() => {
    const handler = () => {
      listProjects().then(setProjects);
    };
    window.addEventListener("orchai:refresh-projects", handler);
    return () => window.removeEventListener("orchai:refresh-projects", handler);
  }, []);

  return (
    <aside className="w-64 bg-gray-900 text-gray-100 flex flex-col h-screen">
      <div className="p-4 border-b border-gray-700">
        <h1 className="text-lg font-bold">Orchai</h1>
      </div>

      <nav className="flex-1 overflow-y-auto p-2">
        <div className="flex items-center justify-between px-2 py-1 mb-1">
          <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
            Projects
          </span>
          <Link
            to="/projects/new"
            className="text-gray-400 hover:text-white text-lg leading-none"
            title="Add project"
          >
            +
          </Link>
        </div>

        {projects.map((project) => (
          <Link
            key={project.id}
            to={`/projects/${project.id}`}
            className={`block px-3 py-2 rounded text-sm ${
              projectId === project.id
                ? "bg-gray-700 text-white"
                : "text-gray-300 hover:bg-gray-800 hover:text-white"
            }`}
          >
            {project.name}
          </Link>
        ))}

        {projects.length === 0 && (
          <p className="px-3 py-2 text-sm text-gray-500">No projects yet</p>
        )}
      </nav>
    </aside>
  );
}
  • Step 2: Create AppLayout component

Create src/components/layout/AppLayout.tsx:

import { Outlet } from "react-router-dom";
import Sidebar from "./Sidebar";

export default function AppLayout() {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <main className="flex-1 overflow-y-auto bg-gray-50">
        <Outlet />
      </main>
    </div>
  );
}
  • Step 3: Set up router in App.tsx

Replace src/App.tsx:

import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import AppLayout from "./components/layout/AppLayout";

function EmptyState() {
  return (
    <div className="flex items-center justify-center h-full text-gray-400">
      <p>Select a project or create a new one</p>
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<AppLayout />}>
          <Route index element={<EmptyState />} />
          <Route path="/projects/new" element={<div className="p-8">Create project (coming next)</div>} />
          <Route path="/projects/:projectId" element={<div className="p-8">Project dashboard (coming next)</div>} />
          <Route path="/projects/:projectId/edit" element={<div className="p-8">Edit project (coming next)</div>} />
          <Route path="*" element={<Navigate to="/" replace />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

export default App;
  • Step 4: Verify the shell renders
npm run tauri dev

Expected: window opens with dark sidebar on the left showing "Orchai" header, "Projects" section with "No projects yet" message, and a "+" button. Main area shows "Select a project or create a new one". Close after verifying.

  • Step 5: Commit
git add -A
git commit -m "feat: React app shell with router, sidebar layout"

Task 8: Project list + create/edit form

Files:

  • Create: src/components/projects/ProjectForm.tsx

  • Create: src/components/projects/ProjectDashboard.tsx

  • Modify: src/App.tsx

  • Step 1: Create ProjectForm component

Create src/components/projects/ProjectForm.tsx:

import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { open } from "@tauri-apps/plugin-dialog";
import { createProject, getProject, updateProject } from "../../lib/api";

export default function ProjectForm() {
  const navigate = useNavigate();
  const { projectId } = useParams();
  const isEditing = Boolean(projectId);

  const [name, setName] = useState("");
  const [pathOrUrl, setPathOrUrl] = useState("");
  const [baseBranch, setBaseBranch] = useState("main");
  const [mode, setMode] = useState<"local" | "clone">("local");
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (projectId) {
      getProject(projectId).then((project) => {
        setName(project.name);
        setPathOrUrl(project.path);
        setBaseBranch(project.base_branch);
        if (project.cloned_from) {
          setMode("clone");
        }
      });
    }
  }, [projectId]);

  async function handleBrowse() {
    const selected = await open({ directory: true, multiple: false });
    if (selected) {
      setPathOrUrl(selected as string);
    }
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setLoading(true);

    try {
      if (isEditing && projectId) {
        await updateProject(projectId, name, baseBranch);
      } else {
        await createProject(name, pathOrUrl, baseBranch);
      }
      window.dispatchEvent(new Event("orchai:refresh-projects"));
      navigate("/");
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : String(err);
      setError(message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="max-w-lg mx-auto p-8">
      <h2 className="text-xl font-bold mb-6">
        {isEditing ? "Edit project" : "New project"}
      </h2>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Project name
          </label>
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            required
            className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>

        {!isEditing && (
          <>
            <div>
              <label className="block text-sm font-medium text-gray-700 mb-1">
                Source
              </label>
              <div className="flex gap-4">
                <label className="flex items-center gap-1 text-sm">
                  <input
                    type="radio"
                    checked={mode === "local"}
                    onChange={() => setMode("local")}
                  />
                  Local folder
                </label>
                <label className="flex items-center gap-1 text-sm">
                  <input
                    type="radio"
                    checked={mode === "clone"}
                    onChange={() => setMode("clone")}
                  />
                  Clone from URL
                </label>
              </div>
            </div>

            <div>
              <label className="block text-sm font-medium text-gray-700 mb-1">
                {mode === "local" ? "Folder path" : "Git URL"}
              </label>
              <div className="flex gap-2">
                <input
                  type="text"
                  value={pathOrUrl}
                  onChange={(e) => setPathOrUrl(e.target.value)}
                  required
                  placeholder={
                    mode === "local"
                      ? "/home/user/code/myproject"
                      : "https://github.com/org/repo.git"
                  }
                  className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
                {mode === "local" && (
                  <button
                    type="button"
                    onClick={handleBrowse}
                    className="px-3 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
                  >
                    Browse
                  </button>
                )}
              </div>
            </div>
          </>
        )}

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Base branch
          </label>
          <input
            type="text"
            value={baseBranch}
            onChange={(e) => setBaseBranch(e.target.value)}
            required
            className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>

        {error && (
          <div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
            {error}
          </div>
        )}

        <div className="flex gap-2">
          <button
            type="submit"
            disabled={loading}
            className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? "Saving..." : isEditing ? "Save" : "Create"}
          </button>
          <button
            type="button"
            onClick={() => navigate(-1)}
            className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
          >
            Cancel
          </button>
        </div>
      </form>
    </div>
  );
}
  • Step 2: Create ProjectDashboard placeholder

Create src/components/projects/ProjectDashboard.tsx:

import { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { getProject, deleteProject } from "../../lib/api";
import type { Project } from "../../lib/types";

export default function ProjectDashboard() {
  const { projectId } = useParams();
  const navigate = useNavigate();
  const [project, setProject] = useState<Project | null>(null);

  useEffect(() => {
    if (projectId) {
      getProject(projectId).then(setProject);
    }
  }, [projectId]);

  async function handleDelete() {
    if (!projectId) return;
    if (!window.confirm(`Delete project "${project?.name}"?`)) return;

    await deleteProject(projectId);
    window.dispatchEvent(new Event("orchai:refresh-projects"));
    navigate("/");
  }

  if (!project) {
    return <div className="p-8 text-gray-400">Loading...</div>;
  }

  return (
    <div className="p-8">
      <div className="flex items-center justify-between mb-6">
        <h2 className="text-xl font-bold">{project.name}</h2>
        <div className="flex gap-2">
          <Link
            to={`/projects/${project.id}/edit`}
            className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300"
          >
            Edit
          </Link>
          <button
            onClick={handleDelete}
            className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
          >
            Delete
          </button>
        </div>
      </div>

      <div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
        <div>
          <span className="text-sm text-gray-500">Path:</span>
          <span className="ml-2 text-sm font-mono">{project.path}</span>
        </div>
        {project.cloned_from && (
          <div>
            <span className="text-sm text-gray-500">Cloned from:</span>
            <span className="ml-2 text-sm font-mono">{project.cloned_from}</span>
          </div>
        )}
        <div>
          <span className="text-sm text-gray-500">Base branch:</span>
          <span className="ml-2 text-sm font-mono">{project.base_branch}</span>
        </div>
        <div>
          <span className="text-sm text-gray-500">Created:</span>
          <span className="ml-2 text-sm">{new Date(project.created_at).toLocaleDateString()}</span>
        </div>
      </div>

      <div className="mt-8 text-gray-400 text-sm">
        Tracker surveillance and ticket processing will be available in the next update.
      </div>
    </div>
  );
}
  • Step 3: Wire up routes in App.tsx

Replace src/App.tsx:

import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import AppLayout from "./components/layout/AppLayout";
import ProjectForm from "./components/projects/ProjectForm";
import ProjectDashboard from "./components/projects/ProjectDashboard";

function EmptyState() {
  return (
    <div className="flex items-center justify-center h-full text-gray-400">
      <p>Select a project or create a new one</p>
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<AppLayout />}>
          <Route index element={<EmptyState />} />
          <Route path="/projects/new" element={<ProjectForm />} />
          <Route path="/projects/:projectId" element={<ProjectDashboard />} />
          <Route path="/projects/:projectId/edit" element={<ProjectForm />} />
          <Route path="*" element={<Navigate to="/" replace />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

export default App;
  • Step 4: Verify the full flow in the browser
npm run tauri dev

Test the following:

  1. Click "+" in the sidebar to navigate to the create form
  2. Fill in a name, select "Local folder", browse to an existing git repo, set base branch
  3. Click "Create" -- project appears in sidebar
  4. Click the project in sidebar -- dashboard shows project details
  5. Click "Edit" -- form pre-fills with project data
  6. Click "Delete" -- project removed from sidebar
  • Step 5: Commit
git add -A
git commit -m "feat: project create/edit/delete UI with folder picker and git clone"

Task 9: Final verification + cleanup

Files:

  • Verify all tests pass

  • Clean up any scaffold files not needed

  • Step 1: Run all Rust tests

cd /home/leclere/Projets/orchai/src-tauri
cargo test

Expected: all tests pass (8 model tests + 3 db tests = 11 tests).

  • Step 2: Run Rust clippy
cd /home/leclere/Projets/orchai/src-tauri
cargo clippy -- -D warnings

Expected: no warnings. If there are warnings, fix them.

  • Step 3: Verify frontend builds
cd /home/leclere/Projets/orchai
npm run build

Expected: Vite build succeeds.

  • Step 4: Clean up scaffold files

Remove any remaining scaffold assets that are not needed:

  • src/assets/react.svg (if still present)

  • Any other default scaffold content

  • Step 5: Final integration test

npm run tauri dev

Test the complete flow one more time:

  1. Create a project pointing to a local git repo
  2. Verify it appears in sidebar
  3. View project dashboard
  4. Edit project name and base branch
  5. Delete the project
  6. Verify sidebar is empty again
  • Step 6: Commit
git add -A
git commit -m "cleanup: remove scaffold assets, verify all tests pass"