feat: productivity suite — task loop, workflow, theming, docs & more (#197)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m39s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled

## Summary

A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation.

### Features

- **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion
- **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control
- **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel
- **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost
- **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume
- **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically
- **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure
- **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine
- **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app
- **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature

### Performance & Fixes

- **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions
- **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected

### UX

- Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation
- Navigation icon cluster replaced with a single clean dropdown menu

## Closes

Closes #179
Closes #180
Closes #181
Closes #188
Closes #189
Closes #190
Closes #191
Closes #192
Closes #193
Closes #194
Closes #195
Closes #196

---

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #197
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #197.
This commit is contained in:
2026-03-07 03:08:33 -08:00
committed by Naomi Carrigan
parent 1ae440659c
commit e6e9f7ae59
52 changed files with 8865 additions and 529 deletions
+210
View File
@@ -606,6 +606,62 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
})
}
#[derive(Debug, serde::Deserialize)]
struct GiteaChangelogRelease {
tag_name: String,
html_url: String,
body: Option<String>,
prerelease: bool,
created_at: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChangelogEntry {
pub version: String,
pub url: String,
pub notes: Option<String>,
pub prerelease: bool,
pub created_at: String,
}
#[tauri::command]
pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
const RELEASES_API: &str =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
let client = reqwest::Client::new();
let response = client
.get(RELEASES_API)
.header("Accept", "application/json")
.query(&[("limit", "50")])
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned status: {}", response.status()));
}
let text = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
let releases: Vec<GiteaChangelogRelease> =
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
Ok(releases
.into_iter()
.map(|r| ChangelogEntry {
version: r.tag_name,
url: r.html_url,
notes: r.body,
prerelease: r.prerelease,
created_at: r.created_at,
})
.collect())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavedFileInfo {
pub path: String,
@@ -2337,6 +2393,160 @@ pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
}
}
// ==================== Codebase Mapper ====================
/// Directories to skip when scanning (always ignored regardless of .gitignore)
const SCAN_SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
".next",
"dist",
"build",
"out",
"__pycache__",
".cache",
".pytest_cache",
"vendor",
".idea",
".vscode",
"coverage",
".nyc_output",
"venv",
".venv",
"env",
".tox",
];
/// Files that indicate the project type
const PROJECT_MARKERS: &[(&str, &str)] = &[
("Cargo.toml", "Rust"),
("package.json", "Node.js"),
("pyproject.toml", "Python"),
("requirements.txt", "Python"),
("go.mod", "Go"),
("pom.xml", "Java (Maven)"),
("build.gradle", "Java (Gradle)"),
("Gemfile", "Ruby"),
("composer.json", "PHP"),
("*.csproj", "C#/.NET"),
("CMakeLists.txt", "C/C++ (CMake)"),
("Makefile", "C/C++"),
];
#[derive(Debug, Serialize)]
pub struct ProjectScan {
pub working_dir: String,
pub file_tree: String,
pub detected_type: String,
pub key_files: Vec<String>,
}
/// Recursively build a file tree string, respecting skip dirs, up to `max_depth` levels.
fn build_file_tree(
dir: &std::path::Path,
prefix: &str,
depth: usize,
max_depth: usize,
lines: &mut Vec<String>,
) {
if depth > max_depth {
lines.push(format!("{}...", prefix));
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut items: Vec<std::fs::DirEntry> = entries
.filter_map(|e| e.ok())
.collect();
items.sort_by_key(|e| {
let name = e.file_name().to_string_lossy().to_lowercase();
// Sort: hidden last, directories first
let is_hidden = name.starts_with('.');
let is_dir = e.path().is_dir();
(is_hidden, !is_dir, name)
});
let count = items.len();
for (i, entry) in items.iter().enumerate() {
let name = entry.file_name().to_string_lossy().to_string();
let is_last = i == count - 1;
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = if is_last {
format!("{} ", prefix)
} else {
format!("{}", prefix)
};
let path = entry.path();
if path.is_dir() {
if SCAN_SKIP_DIRS.contains(&name.as_str()) {
lines.push(format!("{}{}{}/ (skipped)", prefix, connector, name));
continue;
}
lines.push(format!("{}{}{}/", prefix, connector, name));
build_file_tree(&path, &child_prefix, depth + 1, max_depth, lines);
} else {
lines.push(format!("{}{}{}", prefix, connector, name));
}
}
}
#[tauri::command]
pub async fn scan_project(working_dir: String) -> Result<ProjectScan, String> {
let dir_path = std::path::Path::new(&working_dir);
if !dir_path.exists() {
return Err(format!("Directory does not exist: {}", working_dir));
}
if !dir_path.is_dir() {
return Err(format!("Path is not a directory: {}", working_dir));
}
// Detect project type by checking for marker files
let mut detected_type = "Unknown".to_string();
let mut key_files: Vec<String> = Vec::new();
for (marker, project_type) in PROJECT_MARKERS {
let marker_path = dir_path.join(marker);
if marker_path.exists() {
if detected_type == "Unknown" {
detected_type = project_type.to_string();
}
key_files.push(marker.to_string());
}
}
// Also collect other notable root-level files
let notable_root_files = &[
"README.md", "CLAUDE.md", "LICENSE", ".env.example",
"docker-compose.yml", "Dockerfile", ".github",
"tsconfig.json", "vitest.config.ts", "eslint.config.js",
"check-all.sh", "tauri.conf.json",
];
for file in notable_root_files {
let file_path = dir_path.join(file);
if file_path.exists() && !key_files.contains(&file.to_string()) {
key_files.push(file.to_string());
}
}
// Build file tree (max 4 levels deep)
let mut lines: Vec<String> = vec![format!("{}/", working_dir)];
build_file_tree(dir_path, "", 0, 4, &mut lines);
let file_tree = lines.join("\n");
Ok(ProjectScan {
working_dir,
file_tree,
detected_type,
key_files,
})
}
#[cfg(test)]
mod tests {
use super::*;