chore: setup CI scripts (#6)

* wip: workflows

* wip: fix up issues in ci scripts and fix frontend lint errors

* wip: fix backend lints

* remove unused deps

* wip: build frontend in test.yml

* wip: attempt to improve Rust caching

* wip: testing release

* wip: linear release flow

* wip: check against both package.json versions

* wip: spurious attempt to get Rust caching

* wip: more cache

* merge release and publish jobs; add more caching to release flow

* decouple github releases and npm publishing

* update pack flow

---------

Co-authored-by: couscous <couscous@runner.com>
This commit is contained in:
Gabriel Gordon-Hall
2025-06-27 13:32:32 +01:00
committed by GitHub
parent b25f81504a
commit 340b094c75
33 changed files with 620 additions and 280 deletions

29
.github/actions/setup-node/action.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 'Setup Node.js and pnpm'
description: 'Sets up Node.js and pnpm with caching'
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

136
.github/workflows/pre-release.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
# To pre-release:
# ```
# git tag -a v0.1.0 -m "Release 0.1.0"
# git push origin v0.1.0
# ```
name: Create GitHub Pre-Release
on:
push:
tags:
- "v*.*.*"
concurrency:
group: release
cancel-in-progress: true
permissions:
contents: write
packages: write
pull-requests: write
env:
NODE_VERSION: 22
PNPM_VERSION: 10.8.1
TAG_REGEX: '^v[0-9]+\.[0-9]+\.[0-9]+$'
jobs:
tag-check:
runs-on: ubuntu-latest
if: github.ref_type == 'tag'
steps:
- uses: actions/checkout@v4
- name: Validate tag matches package.json version
shell: bash
run: |
set -euo pipefail
echo "::group::Tag validation"
# 1. Must be a tag and match the regex
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|| { echo "❌ Not a tag push"; exit 1; }
[[ "${GITHUB_REF_NAME}" =~ ${TAG_REGEX} ]] \
|| { echo "❌ Tag '${GITHUB_REF_NAME}' != ${TAG_REGEX}"; exit 1; }
# 2. Extract versions
tag_ver="${GITHUB_REF_NAME#v}"
package_ver="$(node -p "require('./package.json').version")"
cli_package_ver="$(node -p "require('./npx-cli/package.json').version")"
# 3. Compare
[[ "${tag_ver}" == "${package_ver}" ]] \
|| { echo "❌ Tag ${tag_ver} ≠ package.json ${package_ver}"; exit 1; }
# 4. Compare tag with npx-cli package.json
[[ "${tag_ver}" == "${cli_package_ver}" ]] \
|| { echo "❌ Tag ${tag_ver} ≠ npx-cli package.json ${cli_package_ver}"; exit 1; }
echo "✅ Tag and package.json agree (${tag_ver})"
echo "::endgroup::"
create-prerelease:
needs: tag-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Install dependencies
run: pnpm install
- name: Lint frontend
run: cd frontend && npm run lint
- name: Type check frontend
run: cd frontend && npx tsc --noEmit
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly-2025-05-18
components: rustfmt, clippy
- name: Build frontend
run: npm run frontend:build
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
env:
RUST_CACHE_DEBUG: true
with:
workspaces: "backend"
- name: Checks
run: |
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --all --all-targets --all-features -- -D warnings
- name: Build backend
run: cargo build --release --manifest-path backend/Cargo.toml
- name: Get version from tag
id: get-version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Zip backend
run: |
mkdir vibe-kanban-${{ github.ref_name }}
mv target/release/vibe-kanban vibe-kanban-${{ github.ref_name }}
mv frontend/dist vibe-kanban-${{ github.ref_name }}
zip -r vibe-kanban-${{ github.ref_name }}.zip vibe-kanban-${{ github.ref_name }}
- name: Code sign
run: echo "pass"
- name: Pack
run: |
mkdir -p npx-cli/dist/linux-x64
mv vibe-kanban-${{ github.ref_name }}.zip npx-cli/dist/linux-x64
cd npx-cli
npm pack
- name: Create GitHub Pre-Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Pre-release ${{ github.ref_name }}
prerelease: true
generate_release_notes: true
files: |
vibe-kanban-${{ github.ref_name }}.zip
npx-cli/vibe-kanban-*.tgz

114
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Publish to npm
on:
release:
types: [released]
concurrency:
group: publish
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
NODE_VERSION: 22
PNPM_VERSION: 10.8.1
jobs:
publish:
runs-on: ubuntu-latest
# Only run if this was converted from a pre-release
if: github.event.release.prerelease == false
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Configure npm authentication
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Download release assets
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Get the release assets
const release = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: context.payload.release.id
});
// Find the .tgz file
const tgzAsset = release.data.assets.find(asset => asset.name.endsWith('.tgz'));
if (!tgzAsset) {
core.setFailed('No .tgz file found in release assets');
return;
}
// Download the asset
const response = await github.rest.repos.getReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
asset_id: tgzAsset.id,
headers: {
Accept: 'application/octet-stream'
}
});
// Save to npx-cli directory
const filePath = path.join('npx-cli', tgzAsset.name);
fs.writeFileSync(filePath, Buffer.from(response.data));
console.log(`Downloaded ${tgzAsset.name} to ${filePath}`);
// Set output for next step
core.setOutput('package-file', filePath);
core.setOutput('package-name', tgzAsset.name);
- name: Verify package integrity
id: verify
run: |
cd npx-cli
# List files to confirm download
ls -la *.tgz
# Verify the package can be read
npm pack --dry-run || echo "Note: This is expected to show differences since we're using the pre-built package"
# Extract package name from the downloaded file
PACKAGE_FILE=$(ls *.tgz | head -n1)
echo "package-file=$PACKAGE_FILE" >> $GITHUB_OUTPUT
- name: Publish to npm
run: |
cd npx-cli
# Publish the exact same package that was tested
PACKAGE_FILE="${{ steps.verify.outputs.package-file }}"
echo "Publishing $PACKAGE_FILE to npm..."
npm publish "$PACKAGE_FILE"
echo "✅ Successfully published to npm!"
- name: Update release description
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: context.payload.release.id,
body: context.payload.release.body + '\n\n✅ **Published to npm registry**'
});

57
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Test
on:
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
CARGO_TERM_COLOR: always
NODE_VERSION: 22
PNPM_VERSION: 10.8.1
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Install dependencies
run: pnpm install
- name: Lint frontend
run: cd frontend && npm run lint
- name: Format check frontend
run: cd frontend && npm run format:check
- name: Type check frontend
run: cd frontend && npx tsc --noEmit
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly-2025-05-18
components: rustfmt, clippy
- name: Build frontend
run: cd frontend && npm run build
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
env:
RUST_CACHE_DEBUG: true
with:
workspaces: "backend"
- name: Checks
run: |
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --all --all-targets --all-features -- -D warnings

View File

@@ -5,12 +5,10 @@ members = ["backend"]
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] } tower-http = { version = "0.5", features = ["cors"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
anyhow = "1.0" anyhow = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
openssl = { version = "0.10", features = ["vendored"] }
openssl-sys = { version = "0.9", features = ["vendored"] } openssl-sys = { version = "0.9", features = ["vendored"] }

View File

@@ -8,10 +8,12 @@ default-run = "vibe-kanban"
name = "vibe_kanban" name = "vibe_kanban"
path = "src/lib.rs" path = "src/lib.rs"
[lints.clippy]
uninlined-format-args = "allow"
[dependencies] [dependencies]
tokio = { workspace = true } tokio = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true } tower-http = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
@@ -21,20 +23,16 @@ tracing-subscriber = { workspace = true }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] } uuid = { version = "1.0", features = ["v4", "serde"] }
bcrypt = "0.15"
jsonwebtoken = "9.2"
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
dirs = "5.0" dirs = "5.0"
git2 = "0.18" git2 = "0.18"
async-trait = "0.1" async-trait = "0.1"
dissimilar = "1.0"
libc = "0.2" libc = "0.2"
rust-embed = "8.2" rust-embed = "8.2"
mime_guess = "2.0" mime_guess = "2.0"
directories = "6.0.0" directories = "6.0.0"
open = "5.3.2" open = "5.3.2"
ignore = "0.4" ignore = "0.4"
openssl = { workspace = true }
openssl-sys = { workspace = true } openssl-sys = { workspace = true }
[build-dependencies] [build-dependencies]

View File

@@ -13,7 +13,7 @@ pub enum ExecutionType {
#[derive(Debug)] #[derive(Debug)]
pub struct RunningExecution { pub struct RunningExecution {
pub task_attempt_id: Uuid, pub task_attempt_id: Uuid,
pub execution_type: ExecutionType, pub _execution_type: ExecutionType,
pub child: tokio::process::Child, pub child: tokio::process::Child,
} }

View File

@@ -81,14 +81,16 @@ async fn play_sound_notification(sound_file: &crate::models::config::SoundFile)
} else if cfg!(target_os = "linux") { } else if cfg!(target_os = "linux") {
// Try different Linux notification sounds // Try different Linux notification sounds
let sound_path = sound_file.to_path(); let sound_path = sound_file.to_path();
if let Ok(_) = tokio::process::Command::new("paplay") if tokio::process::Command::new("paplay")
.arg(&sound_path) .arg(&sound_path)
.spawn() .spawn()
.is_ok()
{ {
// Success with paplay // Success with paplay
} else if let Ok(_) = tokio::process::Command::new("aplay") } else if tokio::process::Command::new("aplay")
.arg(&sound_path) .arg(&sound_path)
.spawn() .spawn()
.is_ok()
{ {
// Success with aplay // Success with aplay
} else { } else {

View File

@@ -124,25 +124,6 @@ pub struct ExecutorConstants {
pub executor_labels: Vec<String>, pub executor_labels: Vec<String>,
} }
impl ExecutorConstants {
pub fn new() -> Self {
Self {
executor_types: vec![
ExecutorConfig::Echo,
ExecutorConfig::Claude,
ExecutorConfig::Amp,
ExecutorConfig::Gemini,
],
executor_labels: vec![
"Echo (Test Mode)".to_string(),
"Claude".to_string(),
"Amp".to_string(),
"Gemini".to_string(),
],
}
}
}
impl ExecutorConfig { impl ExecutorConfig {
pub fn create_executor(&self) -> Box<dyn Executor> { pub fn create_executor(&self) -> Box<dyn Executor> {
match self { match self {

View File

@@ -67,8 +67,8 @@ impl Executor for AmpExecutor {
impl Executor for AmpFollowupExecutor { impl Executor for AmpFollowupExecutor {
async fn spawn( async fn spawn(
&self, &self,
pool: &sqlx::SqlitePool, _pool: &sqlx::SqlitePool,
task_id: Uuid, _task_id: Uuid,
worktree_path: &str, worktree_path: &str,
) -> Result<Child, ExecutorError> { ) -> Result<Child, ExecutorError> {
use std::process::Stdio; use std::process::Stdio;

View File

@@ -62,8 +62,8 @@ impl Executor for ClaudeExecutor {
impl Executor for ClaudeFollowupExecutor { impl Executor for ClaudeFollowupExecutor {
async fn spawn( async fn spawn(
&self, &self,
pool: &sqlx::SqlitePool, _pool: &sqlx::SqlitePool,
task_id: Uuid, _task_id: Uuid,
worktree_path: &str, worktree_path: &str,
) -> Result<Child, ExecutorError> { ) -> Result<Child, ExecutorError> {
// Use Claude CLI with --resume flag to continue the session // Use Claude CLI with --resume flag to continue the session

View File

@@ -60,8 +60,8 @@ impl Executor for GeminiExecutor {
impl Executor for GeminiFollowupExecutor { impl Executor for GeminiFollowupExecutor {
async fn spawn( async fn spawn(
&self, &self,
pool: &sqlx::SqlitePool, _pool: &sqlx::SqlitePool,
task_id: Uuid, _task_id: Uuid,
worktree_path: &str, worktree_path: &str,
) -> Result<Child, ExecutorError> { ) -> Result<Child, ExecutorError> {
// Use Gemini CLI with session resumption (if supported) // Use Gemini CLI with session resumption (if supported)

View File

@@ -189,7 +189,7 @@ async fn main() -> anyhow::Result<()> {
let port: u16 = std::env::var("PORT") let port: u16 = std::env::var("PORT")
.ok() .ok()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or_else(|| if cfg!(debug_assertions) { 3001 } else { 0 }); .unwrap_or(if cfg!(debug_assertions) { 3001 } else { 0 });
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
let actual_port = listener.local_addr()?.port(); // get → 53427 (example) let actual_port = listener.local_addr()?.port(); // get → 53427 (example)

View File

@@ -97,6 +97,12 @@ impl EditorConstants {
} }
} }
impl Default for EditorConstants {
fn default() -> Self {
Self::new()
}
}
impl SoundConstants { impl SoundConstants {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -122,6 +128,12 @@ impl SoundConstants {
} }
} }
impl Default for SoundConstants {
fn default() -> Self {
Self::new()
}
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {

View File

@@ -80,6 +80,7 @@ pub struct CreateExecutionProcess {
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
#[ts(export)] #[ts(export)]
#[allow(dead_code)]
pub struct UpdateExecutionProcess { pub struct UpdateExecutionProcess {
pub status: Option<ExecutionProcessStatus>, pub status: Option<ExecutionProcessStatus>,
pub exit_code: Option<i64>, pub exit_code: Option<i64>,
@@ -392,6 +393,7 @@ impl ExecutionProcess {
} }
/// Delete execution processes for a task attempt (cleanup) /// Delete execution processes for a task attempt (cleanup)
#[allow(dead_code)]
pub async fn delete_by_task_attempt_id( pub async fn delete_by_task_attempt_id(
pool: &SqlitePool, pool: &SqlitePool,
task_attempt_id: Uuid, task_attempt_id: Uuid,

View File

@@ -26,6 +26,7 @@ pub struct CreateExecutorSession {
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
#[ts(export)] #[ts(export)]
#[allow(dead_code)]
pub struct UpdateExecutorSession { pub struct UpdateExecutorSession {
pub session_id: Option<String>, pub session_id: Option<String>,
pub prompt: Option<String>, pub prompt: Option<String>,
@@ -33,6 +34,7 @@ pub struct UpdateExecutorSession {
impl ExecutorSession { impl ExecutorSession {
/// Find executor session by ID /// Find executor session by ID
#[allow(dead_code)]
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
ExecutorSession, ExecutorSession,
@@ -76,6 +78,7 @@ impl ExecutorSession {
} }
/// Find all executor sessions for a task attempt /// Find all executor sessions for a task attempt
#[allow(dead_code)]
pub async fn find_by_task_attempt_id( pub async fn find_by_task_attempt_id(
pool: &SqlitePool, pool: &SqlitePool,
task_attempt_id: Uuid, task_attempt_id: Uuid,
@@ -154,6 +157,7 @@ impl ExecutorSession {
} }
/// Update executor session prompt /// Update executor session prompt
#[allow(dead_code)]
pub async fn update_prompt( pub async fn update_prompt(
pool: &SqlitePool, pool: &SqlitePool,
id: Uuid, id: Uuid,
@@ -173,6 +177,7 @@ impl ExecutorSession {
} }
/// Delete executor sessions for a task attempt (cleanup) /// Delete executor sessions for a task attempt (cleanup)
#[allow(dead_code)]
pub async fn delete_by_task_attempt_id( pub async fn delete_by_task_attempt_id(
pool: &SqlitePool, pool: &SqlitePool,
task_attempt_id: Uuid, task_attempt_id: Uuid,

View File

@@ -620,6 +620,7 @@ impl TaskAttempt {
} }
/// Unified function to start any type of process execution /// Unified function to start any type of process execution
#[allow(clippy::too_many_arguments)]
async fn start_process_execution( async fn start_process_execution(
pool: &SqlitePool, pool: &SqlitePool,
app_state: &crate::app_state::AppState, app_state: &crate::app_state::AppState,
@@ -935,7 +936,7 @@ impl TaskAttempt {
process_id, process_id,
crate::app_state::RunningExecution { crate::app_state::RunningExecution {
task_attempt_id: attempt_id, task_attempt_id: attempt_id,
execution_type, _execution_type: execution_type,
child, child,
}, },
) )

View File

@@ -36,6 +36,7 @@ pub struct TaskAttemptActivityWithPrompt {
} }
impl TaskAttemptActivity { impl TaskAttemptActivity {
#[allow(dead_code)]
pub async fn find_by_execution_process_id( pub async fn find_by_execution_process_id(
pool: &SqlitePool, pool: &SqlitePool,
execution_process_id: Uuid, execution_process_id: Uuid,
@@ -73,6 +74,7 @@ impl TaskAttemptActivity {
.await .await
} }
#[allow(dead_code)]
pub async fn find_processes_with_latest_running_status( pub async fn find_processes_with_latest_running_status(
pool: &SqlitePool, pool: &SqlitePool,
) -> Result<Vec<uuid::Uuid>, sqlx::Error> { ) -> Result<Vec<uuid::Uuid>, sqlx::Error> {

View File

@@ -58,31 +58,29 @@ pub async fn list_directory(
Ok(entries) => { Ok(entries) => {
let mut directory_entries = Vec::new(); let mut directory_entries = Vec::new();
for entry in entries { for entry in entries.flatten() {
if let Ok(entry) = entry { let path = entry.path();
let path = entry.path(); let metadata = entry.metadata().ok();
let metadata = entry.metadata().ok();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
// Skip hidden files/directories // Skip hidden files/directories
if name.starts_with('.') && name != ".." { if name.starts_with('.') && name != ".." {
continue; continue;
}
let is_directory = metadata.is_some_and(|m| m.is_dir());
let is_git_repo = if is_directory {
path.join(".git").exists()
} else {
false
};
directory_entries.push(DirectoryEntry {
name: name.to_string(),
path: path.to_string_lossy().to_string(),
is_directory,
is_git_repo,
});
} }
let is_directory = metadata.is_some_and(|m| m.is_dir());
let is_git_repo = if is_directory {
path.join(".git").exists()
} else {
false
};
directory_entries.push(DirectoryEntry {
name: name.to_string(),
path: path.to_string_lossy().to_string(),
is_directory,
is_git_repo,
});
} }
} }

View File

@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 100",
"lint:fix": "eslint . --ext ts,tsx --fix", "lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -36,7 +36,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const [showEditForm, setShowEditForm] = useState(false); const [showEditForm, setShowEditForm] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const fetchProject = async () => { const fetchProject = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
@@ -55,7 +55,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [projectId]);
const handleDelete = async () => { const handleDelete = async () => {
if (!project) return; if (!project) return;
@@ -86,7 +86,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
useEffect(() => { useEffect(() => {
fetchProject(); fetchProject();
}, [projectId]); }, [fetchProject]);
if (loading) { if (loading) {
return ( return (

View File

@@ -42,7 +42,10 @@ export function ExecutionOutputViewer({
// Check if stdout looks like JSONL (for Amp, Claude, or Gemini executor) // Check if stdout looks like JSONL (for Amp, Claude, or Gemini executor)
const { isValidJsonl, jsonlFormat } = useMemo(() => { const { isValidJsonl, jsonlFormat } = useMemo(() => {
if ((!isAmpExecutor && !isClaudeExecutor && !isGeminiExecutor) || !executionProcess.stdout) { if (
(!isAmpExecutor && !isClaudeExecutor && !isGeminiExecutor) ||
!executionProcess.stdout
) {
return { isValidJsonl: false, jsonlFormat: null }; return { isValidJsonl: false, jsonlFormat: null };
} }
@@ -99,7 +102,12 @@ export function ExecutionOutputViewer({
} catch { } catch {
return { isValidJsonl: false, jsonlFormat: null }; return { isValidJsonl: false, jsonlFormat: null };
} }
}, [isAmpExecutor, isClaudeExecutor, isGeminiExecutor, executionProcess.stdout]); }, [
isAmpExecutor,
isClaudeExecutor,
isGeminiExecutor,
executionProcess.stdout,
]);
// Set initial view mode based on JSONL detection // Set initial view mode based on JSONL detection
useEffect(() => { useEffect(() => {

View File

@@ -86,9 +86,7 @@ export function TaskActivityHistory({
return ( return (
<div> <div>
<Label className="text-sm font-medium mb-3 block"> <Label className="text-sm font-medium mb-3 block">Activity History</Label>
Activity History
</Label>
{activities.length === 0 ? ( {activities.length === 0 ? (
<div className="text-center py-4 text-muted-foreground"> <div className="text-center py-4 text-muted-foreground">
No activities found No activities found

View File

@@ -112,7 +112,7 @@ export function TaskDetailsHeader({
<TooltipContent> <TooltipContent>
<p>Close panel</p> <p>Close panel</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
</div> </div>

View File

@@ -9,11 +9,7 @@ import {
getTaskPanelClasses, getTaskPanelClasses,
getBackdropClasses, getBackdropClasses,
} from '@/lib/responsive-config'; } from '@/lib/responsive-config';
import type { import type { TaskWithAttemptStatus, EditorType, Project } from 'shared/types';
TaskWithAttemptStatus,
EditorType,
Project,
} from 'shared/types';
interface TaskDetailsPanelProps { interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null; task: TaskWithAttemptStatus | null;

View File

@@ -105,9 +105,7 @@ export function TaskDetailsToolbar({
<div className="h-4 w-px bg-border" /> <div className="h-4 w-px bg-border" />
</> </>
) : ( ) : (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">No attempts yet</div>
No attempts yet
</div>
)} )}
</div> </div>
@@ -171,9 +169,7 @@ export function TaskDetailsToolbar({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>
{isStopping {isStopping ? 'Stopping execution...' : 'Stop execution'}
? 'Stopping execution...'
: 'Stop execution'}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -230,8 +226,7 @@ export function TaskDetailsToolbar({
} }
> >
{executor.name} {executor.name}
{config?.executor.type === executor.id && {config?.executor.type === executor.id && ' (Default)'}
' (Default)'}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -257,16 +252,14 @@ export function TaskDetailsToolbar({
onMouseLeave={() => onSetIsHoveringDevServer(false)} onMouseLeave={() => onSetIsHoveringDevServer(false)}
> >
<Button <Button
variant={ variant={runningDevServer ? 'destructive' : 'outline'}
runningDevServer ? 'destructive' : 'outline'
}
size="sm" size="sm"
onClick={ onClick={
runningDevServer ? onStopDevServer : onStartDevServer runningDevServer
} ? onStopDevServer
disabled={ : onStartDevServer
isStartingDevServer || !project?.dev_script
} }
disabled={isStartingDevServer || !project?.dev_script}
> >
{runningDevServer ? ( {runningDevServer ? (
<StopCircle className="h-4 w-4" /> <StopCircle className="h-4 w-4" />

View File

@@ -66,9 +66,7 @@ export function TaskFollowUpSection({
<Button <Button
onClick={onSendFollowUp} onClick={onSendFollowUp}
disabled={ disabled={
!canSendFollowUp || !canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp
!followUpMessage.trim() ||
isSendingFollowUp
} }
size="sm" size="sm"
> >

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -103,7 +103,7 @@ export function TaskFormDialog({
} }
}; };
const handleCreateAndStart = async () => { const handleCreateAndStart = useCallback(async () => {
if (!title.trim()) return; if (!title.trim()) return;
setIsSubmittingAndStart(true); setIsSubmittingAndStart(true);
@@ -121,9 +121,16 @@ export function TaskFormDialog({
} finally { } finally {
setIsSubmittingAndStart(false); setIsSubmittingAndStart(false);
} }
}; }, [
title,
description,
config?.executor,
isEditMode,
onCreateAndStartTask,
onOpenChange,
]);
const handleCancel = () => { const handleCancel = useCallback(() => {
// Reset form state when canceling // Reset form state when canceling
if (task) { if (task) {
setTitle(task.title); setTitle(task.title);
@@ -135,7 +142,7 @@ export function TaskFormDialog({
setStatus('todo'); setStatus('todo');
} }
onOpenChange(false); onOpenChange(false);
}; }, [task, onOpenChange]);
// Handle keyboard shortcuts // Handle keyboard shortcuts
useEffect(() => { useEffect(() => {

View File

@@ -56,7 +56,10 @@ export function useTaskDetails(
return false; return false;
} }
const latestActivitiesByProcess = new Map<string, TaskAttemptActivityWithPrompt>(); const latestActivitiesByProcess = new Map<
string,
TaskAttemptActivityWithPrompt
>();
attemptData.activities.forEach((activity) => { attemptData.activities.forEach((activity) => {
const existing = latestActivitiesByProcess.get( const existing = latestActivitiesByProcess.get(
@@ -110,70 +113,78 @@ export function useTaskDetails(
const lines = allOutput.split('\n').filter((line) => line.trim()); const lines = allOutput.split('\n').filter((line) => line.trim());
const lastLines = lines.slice(-10); const lastLines = lines.slice(-10);
return lastLines.length > 0 ? lastLines.join('\n') : 'No output yet...'; return lastLines.length > 0 ? lastLines.join('\n') : 'No output yet...';
}, [devServerDetails?.stdout, devServerDetails?.stderr]); }, [devServerDetails]);
// Set default executor from config // Define callbacks first
useEffect(() => { const fetchAttemptData = useCallback(
if (config) { async (attemptId: string) => {
setSelectedExecutor(config.executor.type); if (!task) return;
}
}, [config]);
useEffect(() => { try {
if (task && isOpen) { const [activitiesResponse, processesResponse] = await Promise.all([
fetchTaskAttempts(); makeRequest(
} `/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
}, [task, isOpen]); ),
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes`
),
]);
// Polling for updates when attempt is running if (activitiesResponse.ok && processesResponse.ok) {
useEffect(() => { const activitiesResult: ApiResponse<TaskAttemptActivityWithPrompt[]> =
if (!isAttemptRunning || !task) return; await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
await processesResponse.json();
const interval = setInterval(() => { if (
if (selectedAttempt) { activitiesResult.success &&
fetchAttemptData(selectedAttempt.id, true); processesResult.success &&
} activitiesResult.data &&
}, 2000); processesResult.data
) {
const runningActivities = activitiesResult.data.filter(
(activity) =>
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
);
return () => clearInterval(interval); const runningProcessDetails: Record<string, ExecutionProcess> = {};
}, [isAttemptRunning, task?.id, selectedAttempt?.id]); for (const activity of runningActivities) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[activity.execution_process_id] =
detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch execution process ${activity.execution_process_id}:`,
err
);
}
}
// Fetch dev server details when hovering setAttemptData({
const fetchDevServerDetails = useCallback(async () => { activities: activitiesResult.data,
if (!runningDevServer || !task || !selectedAttempt) return; processes: processesResult.data,
runningProcessDetails,
try { });
const response = await makeRequest( }
`/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
);
if (response.ok) {
const result: ApiResponse<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
} }
} catch (err) {
console.error('Failed to fetch attempt data:', err);
} }
} catch (err) { },
console.error('Failed to fetch dev server details:', err); [task, projectId]
} );
}, [runningDevServer?.id, task?.id, selectedAttempt?.id, projectId]);
// Poll dev server details while hovering const fetchTaskAttempts = useCallback(async () => {
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [
isHoveringDevServer,
runningDevServer?.id,
fetchDevServerDetails,
]);
const fetchTaskAttempts = async () => {
if (!task) return; if (!task) return;
try { try {
@@ -210,75 +221,64 @@ export function useTaskDetails(
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [task, projectId, fetchAttemptData]);
const fetchAttemptData = async ( // Fetch dev server details when hovering
attemptId: string, const fetchDevServerDetails = useCallback(async () => {
_isBackgroundUpdate = false if (!runningDevServer || !task || !selectedAttempt) return;
) => {
if (!task) return;
try { try {
const [activitiesResponse, processesResponse] = await Promise.all([ const response = await makeRequest(
makeRequest( `/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities` );
), if (response.ok) {
makeRequest( const result: ApiResponse<ExecutionProcess> = await response.json();
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes` if (result.success && result.data) {
), setDevServerDetails(result.data);
]);
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivityWithPrompt[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
await processesResponse.json();
if (
activitiesResult.success &&
processesResult.success &&
activitiesResult.data &&
processesResult.data
) {
const runningActivities = activitiesResult.data.filter(
(activity) =>
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
);
const runningProcessDetails: Record<string, ExecutionProcess> = {};
for (const activity of runningActivities) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[activity.execution_process_id] =
detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch execution process ${activity.execution_process_id}:`,
err
);
}
}
setAttemptData({
activities: activitiesResult.data,
processes: processesResult.data,
runningProcessDetails,
});
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch attempt data:', err); console.error('Failed to fetch dev server details:', err);
} }
}; }, [runningDevServer, task, selectedAttempt, projectId]);
// Set default executor from config
useEffect(() => {
if (config) {
setSelectedExecutor(config.executor.type);
}
}, [config]);
useEffect(() => {
if (task && isOpen) {
fetchTaskAttempts();
}
}, [task, isOpen, fetchTaskAttempts]);
// Polling for updates when attempt is running
useEffect(() => {
if (!isAttemptRunning || !task) return;
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptData(selectedAttempt.id);
}
}, 2000);
return () => clearInterval(interval);
}, [isAttemptRunning, task, selectedAttempt, fetchAttemptData]);
// Poll dev server details while hovering
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [isHoveringDevServer, runningDevServer, fetchDevServerDetails]);
const handleAttemptChange = (attemptId: string) => { const handleAttemptChange = (attemptId: string) => {
const attempt = taskAttempts.find((a) => a.id === attemptId); const attempt = taskAttempts.find((a) => a.id === attemptId);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@@ -90,7 +90,7 @@ export function ProjectTasks() {
} }
}, [taskId, tasks]); }, [taskId, tasks]);
const fetchProject = async () => { const fetchProject = useCallback(async () => {
try { try {
const response = await makeRequest( const response = await makeRequest(
`/api/projects/${projectId}/with-branch` `/api/projects/${projectId}/with-branch`
@@ -108,53 +108,56 @@ export function ProjectTasks() {
} catch (err) { } catch (err) {
setError('Failed to load project'); setError('Failed to load project');
} }
}; }, [projectId, navigate]);
const fetchTasks = async (skipLoading = false) => { const fetchTasks = useCallback(
try { async (skipLoading = false) => {
if (!skipLoading) { try {
setLoading(true); if (!skipLoading) {
} setLoading(true);
const response = await makeRequest(`/api/projects/${projectId}/tasks`);
if (response.ok) {
const result: ApiResponse<Task[]> = await response.json();
if (result.success && result.data) {
// Only update if data has actually changed
setTasks((prevTasks) => {
const newTasks = result.data!;
if (JSON.stringify(prevTasks) === JSON.stringify(newTasks)) {
return prevTasks; // Return same reference to prevent re-render
}
// Update selectedTask if it exists and has been modified
if (selectedTask) {
const updatedSelectedTask = newTasks.find(
(task) => task.id === selectedTask.id
);
if (
updatedSelectedTask &&
JSON.stringify(selectedTask) !==
JSON.stringify(updatedSelectedTask)
) {
setSelectedTask(updatedSelectedTask);
}
}
return newTasks;
});
} }
} else { const response = await makeRequest(`/api/projects/${projectId}/tasks`);
if (response.ok) {
const result: ApiResponse<Task[]> = await response.json();
if (result.success && result.data) {
// Only update if data has actually changed
setTasks((prevTasks) => {
const newTasks = result.data!;
if (JSON.stringify(prevTasks) === JSON.stringify(newTasks)) {
return prevTasks; // Return same reference to prevent re-render
}
// Update selectedTask if it exists and has been modified
if (selectedTask) {
const updatedSelectedTask = newTasks.find(
(task) => task.id === selectedTask.id
);
if (
updatedSelectedTask &&
JSON.stringify(selectedTask) !==
JSON.stringify(updatedSelectedTask)
) {
setSelectedTask(updatedSelectedTask);
}
}
return newTasks;
});
}
} else {
setError('Failed to load tasks');
}
} catch (err) {
setError('Failed to load tasks'); setError('Failed to load tasks');
} finally {
if (!skipLoading) {
setLoading(false);
}
} }
} catch (err) { },
setError('Failed to load tasks'); [projectId, selectedTask]
} finally { );
if (!skipLoading) {
setLoading(false);
}
}
};
const handleCreateTask = async (title: string, description: string) => { const handleCreateTask = async (title: string, description: string) => {
try { try {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -59,14 +59,8 @@ export function TaskAttemptComparePage() {
const [fileToDelete, setFileToDelete] = useState<string | null>(null); const [fileToDelete, setFileToDelete] = useState<string | null>(null);
const [showUncommittedWarning, setShowUncommittedWarning] = useState(false); const [showUncommittedWarning, setShowUncommittedWarning] = useState(false);
useEffect(() => { // Define callbacks first
if (projectId && taskId && attemptId) { const fetchDiff = useCallback(async () => {
fetchDiff();
fetchBranchStatus();
}
}, [projectId, taskId, attemptId]);
const fetchDiff = async () => {
if (!projectId || !taskId || !attemptId) return; if (!projectId || !taskId || !attemptId) return;
try { try {
@@ -90,9 +84,9 @@ export function TaskAttemptComparePage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [projectId, taskId, attemptId]);
const fetchBranchStatus = async () => { const fetchBranchStatus = useCallback(async () => {
if (!projectId || !taskId || !attemptId) return; if (!projectId || !taskId || !attemptId) return;
try { try {
@@ -116,7 +110,14 @@ export function TaskAttemptComparePage() {
} finally { } finally {
setBranchStatusLoading(false); setBranchStatusLoading(false);
} }
}; }, [projectId, taskId, attemptId]);
useEffect(() => {
if (projectId && taskId && attemptId) {
fetchDiff();
fetchBranchStatus();
}
}, [projectId, taskId, attemptId, fetchDiff, fetchBranchStatus]);
const handleBackClick = () => { const handleBackClick = () => {
navigate(`/projects/${projectId}/tasks/${taskId}`); navigate(`/projects/${projectId}/tasks/${taskId}`);

View File

@@ -1,7 +1,7 @@
{ {
"name": "vibe-kanban", "name": "vibe-kanban",
"private": false, "private": false,
"version": "0.0.19", "version": "0.0.1",
"main": "index.js", "main": "index.js",
"bin": { "bin": {
"my-npx-cli": "bin/cli.js" "my-npx-cli": "bin/cli.js"

View File

@@ -1,5 +1,6 @@
{ {
"name": "vibe-kanban", "name": "vibe-kanban",
"version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently \"cargo watch -w backend -x 'run --manifest-path backend/Cargo.toml'\" \"npm run frontend:dev\"", "dev": "concurrently \"cargo watch -w backend -x 'run --manifest-path backend/Cargo.toml'\" \"npm run frontend:dev\"",