diff --git a/appicon.ico b/appicon.ico new file mode 100644 index 0000000..10d15dd Binary files /dev/null and b/appicon.ico differ diff --git a/backend/handlers.go b/backend/handlers.go index 26f5062..35eb8fd 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -1,8 +1,10 @@ package main import ( + "bytes" "encoding/json" "fmt" + "io" "net/http" "os" "strings" @@ -11,6 +13,8 @@ import ( "fn-registry/functions/infra" ) +const agentRunnerBase = "http://127.0.0.1:8486" + func (s *Server) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/health", s.handleHealth) mux.HandleFunc("/api/issues", s.handleIssues) @@ -19,6 +23,8 @@ func (s *Server) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/flows/", s.handleFlowByID) mux.HandleFunc("/api/meta", s.handleMeta) mux.HandleFunc("/api/sse", s.handleSSE) + mux.HandleFunc("/api/agent_status", s.handleAgentStatus) + mux.HandleFunc("/api/agent_launch", s.handleAgentLaunch) } func writeJSON(w http.ResponseWriter, status int, v any) { @@ -279,13 +285,81 @@ func (s *Server) handleFlowByID(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{ - "statuses": []string{"pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"}, - "priorities": []string{"critica", "alta", "media", "baja"}, - "scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"}, - "types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"}, + "statuses": []string{"ideas", "pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"}, + "board_columns": []string{"ideas", "pendiente", "in-progress", "completado"}, + "priorities": []string{"critica", "alta", "media", "baja"}, + "scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"}, + "types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"}, }) } +// GET /api/agent_status — proxies agent_runner_api running runs, returns map issue_id -> run_id +func (s *Server) handleAgentStatus(w http.ResponseWriter, r *http.Request) { + resp, err := http.Get(agentRunnerBase + "/api/runs?status=running") + if err != nil { + writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var runs []map[string]any + if err := json.Unmarshal(body, &runs); err != nil { + writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}}) + return + } + active := map[string]string{} + for _, run := range runs { + issueID, _ := run["issue_id"].(string) + runID, _ := run["id"].(string) + if issueID != "" && runID != "" { + active[issueID] = runID + } + } + writeJSON(w, 200, map[string]any{"available": true, "active": active}) +} + +// POST /api/agent_launch {"issue_id":"NNNN"} — forwards to agent_runner_api +func (s *Server) handleAgentLaunch(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeErr(w, 405, "method not allowed") + return + } + var req struct { + IssueID string `json:"issue_id"` + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "bad json") + return + } + if req.IssueID == "" { + writeErr(w, 400, "issue_id required") + return + } + if req.Mode == "" { + req.Mode = "fix-issue" + } + payload, _ := json.Marshal(map[string]string{ + "issue_id": req.IssueID, + "mode": req.Mode, + "kanban_app": "kanban_cpp", + }) + resp, err := http.Post(agentRunnerBase+"/api/runs", "application/json", bytes.NewReader(payload)) + if err != nil { + writeErr(w, 502, fmt.Sprintf("agent_runner_api unreachable: %v", err)) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + writeErr(w, resp.StatusCode, string(body)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) +} + func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/backend/kanban_cpp_backend.exe b/backend/kanban_cpp_backend.exe new file mode 100755 index 0000000..3e7cf96 Binary files /dev/null and b/backend/kanban_cpp_backend.exe differ diff --git a/data.cpp b/data.cpp index 350309f..8bd76f2 100644 --- a/data.cpp +++ b/data.cpp @@ -86,6 +86,16 @@ static fn_http::Response http_patch_json(const std::string& path, const std::str return fn_http::request(req); } +static fn_http::Response http_post_json(const std::string& path, const std::string& body) { + fn_http::Request req; + req.method = "POST"; + req.url = state().backend_url + path; + req.timeout_ms = 10000; + req.headers.push_back({"Content-Type", "application/json"}); + req.body = body; + return fn_http::request(req); +} + bool refresh_issues() { auto resp = http_get("/api/issues"); if (resp.status != 200) { @@ -179,6 +189,51 @@ bool patch_issue_status(const std::string& id, const std::string& new_status) { return true; } +bool refresh_agent_status() { + auto resp = http_get("/api/agent_status"); + if (resp.status != 200) { + std::lock_guard g(state().mu); + state().agent_runner_up = false; + state().agent_active.clear(); + return false; + } + auto j = json::parse(resp.body, nullptr, false); + if (j.is_discarded()) return false; + std::map active; + if (j.contains("active") && j["active"].is_object()) { + for (auto it = j["active"].begin(); it != j["active"].end(); ++it) { + if (it.value().is_string()) active[it.key()] = it.value().get(); + } + } + bool up = j.value("available", false); + std::lock_guard g(state().mu); + state().agent_runner_up = up; + state().agent_active = std::move(active); + return true; +} + +bool launch_agent(const std::string& issue_id) { + std::string body = "{\"issue_id\":" + json_escape(issue_id) + ",\"mode\":\"fix-issue\"}"; + auto resp = http_post_json("/api/agent_launch", body); + if (resp.status < 200 || resp.status >= 300) { + std::lock_guard g(state().mu); + state().last_launch_msg = "launch failed (" + std::to_string(resp.status) + "): " + resp.body; + return false; + } + { + std::lock_guard g(state().mu); + state().last_launch_msg = "launched agent on " + issue_id; + // Optimistically mark as active so the dot shows up immediately. + state().agent_active[issue_id] = "pending"; + } + return true; +} + +bool is_agent_active(const std::string& issue_id) { + std::lock_guard g(state().mu); + return state().agent_active.find(issue_id) != state().agent_active.end(); +} + bool patch_issue_fields(const std::string& id, const std::string& json_partial) { auto resp = http_patch_json("/api/issues/" + id, json_partial); if (resp.status != 200) { diff --git a/data.h b/data.h index 878256e..f604cff 100644 --- a/data.h +++ b/data.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -48,20 +49,25 @@ struct Filters { std::set priorities; std::set tags; bool include_completed = false; + bool show_issues = true; + bool show_flows = true; }; struct State { - std::mutex mu; - std::string backend_url = "http://127.0.0.1:8487"; - std::vector issues; - std::vector flows; - Meta meta; - Filters filters; - std::string selected_issue_id; - Issue selected_issue_detail; - bool loading = false; - std::string last_error; - long long last_refresh_ns = 0; + std::mutex mu; + std::string backend_url = "http://127.0.0.1:8487"; + std::vector issues; + std::vector flows; + Meta meta; + Filters filters; + std::string selected_issue_id; + Issue selected_issue_detail; + bool loading = false; + std::string last_error; + long long last_refresh_ns = 0; + std::map agent_active; // issue_id -> run_id + bool agent_runner_up = false; + std::string last_launch_msg; }; State& state(); @@ -73,6 +79,9 @@ bool refresh_meta(); bool refresh_issue_detail(const std::string& id); bool patch_issue_status(const std::string& id, const std::string& new_status); bool patch_issue_fields(const std::string& id, const std::string& json_partial); +bool refresh_agent_status(); +bool launch_agent(const std::string& issue_id); +bool is_agent_active(const std::string& issue_id); // Background SSE subscription (no-op if already started). void start_sse(); diff --git a/main.cpp b/main.cpp index 3bf0140..5948c9b 100644 --- a/main.cpp +++ b/main.cpp @@ -28,12 +28,19 @@ static void start_refresh_thread() { kanban::refresh_meta(); kanban::refresh_issues(); kanban::refresh_flows(); + kanban::refresh_agent_status(); kanban::start_sse(); + int tick = 0; while (g_refresh_thread_alive) { - std::this_thread::sleep_for(std::chrono::seconds(30)); + // Fast loop (every 3s) for agent status; full refresh every 30s. + std::this_thread::sleep_for(std::chrono::seconds(3)); if (!g_refresh_thread_alive) break; - kanban::refresh_issues(); - kanban::refresh_flows(); + kanban::refresh_agent_status(); + if (++tick >= 10) { + tick = 0; + kanban::refresh_issues(); + kanban::refresh_flows(); + } } kanban::stop_sse(); }); diff --git a/panel_board.cpp b/panel_board.cpp index aa7b1ae..0891b83 100644 --- a/panel_board.cpp +++ b/panel_board.cpp @@ -3,17 +3,29 @@ #include #include +#include #include #include +#include #include #include "core/icons_tabler.h" namespace kanban { -static const char* k_columns[] = {"pendiente", "in-progress", "bloqueado", "completado"}; +static const char* k_columns[] = {"ideas", "pendiente", "in-progress", "completado"}; +static const char* k_column_icons[] = {TI_BULB, TI_CLIPBOARD, TI_TOOLS, TI_CIRCLE_CHECK}; static const int k_n_columns = sizeof(k_columns) / sizeof(k_columns[0]); +// Map flow status (free-form) → one of the 4 board columns. +static int flow_column_index(const std::string& s) { + if (s == "draft" || s == "ideas") return 0; + if (s == "active" || s == "in-progress") return 2; + if (s == "done" || s == "completed" || s == "completado") return 3; + // pending, paused, "" → pendiente + return 1; +} + static ImU32 priority_color(const std::string& p) { if (p == "critica") return IM_COL32(255, 80, 80, 255); if (p == "alta") return IM_COL32(255, 165, 0, 255); @@ -22,14 +34,25 @@ static ImU32 priority_color(const std::string& p) { return IM_COL32(180, 180, 180, 255); } +static ImU32 status_tint(const std::string& s) { + if (s == "bloqueado") return IM_COL32(120, 60, 60, 200); + if (s == "completado") return IM_COL32( 60, 110, 60, 100); + if (s == "in-progress") return IM_COL32( 70, 90, 140, 100); + if (s == "ideas") return IM_COL32(110, 90, 140, 100); + return IM_COL32(60, 60, 70, 100); +} + static void draw_card(const Issue& iss) { ImGui::PushID(iss.id.c_str()); - ImVec2 size(0.0f, 64.0f); + const float card_h = 110.0f; + ImVec2 size(0.0f, card_h); + ImU32 bg = status_tint(iss.status); + ImGui::PushStyleColor(ImGuiCol_ChildBg, bg); ImGui::BeginChild("card", size, true, ImGuiWindowFlags_NoScrollbar); - // Header: id + priority badge - ImGui::TextColored(ImColor(180, 180, 180, 255), "%s", iss.id.c_str()); + // Top row: id · priority dot · agent icon (right-aligned) · block badge if blocked + ImGui::TextColored(ImColor(200, 200, 200, 255), "%s", iss.id.c_str()); ImGui::SameLine(); { ImVec2 cur = ImGui::GetCursorScreenPos(); @@ -39,22 +62,42 @@ static void draw_card(const Issue& iss) { dl->AddCircleFilled(ImVec2(cur.x + r, cur.y + r + 2), r, c); ImGui::Dummy(ImVec2(2 * r + 4, 2 * r)); ImGui::SameLine(); - ImGui::TextUnformatted(iss.priority.c_str()); + ImGui::TextColored(ImColor(180, 180, 180, 255), "%s", iss.priority.c_str()); } - // Title (truncate to ~60 chars) + // Right-aligned indicators (agent active + blocked). + { + std::string indicators; + bool active = is_agent_active(iss.id); + if (active) indicators += TI_ROBOT; + if (iss.status == "bloqueado") { if (!indicators.empty()) indicators += " "; indicators += TI_LOCK; } + if (!indicators.empty()) { + ImVec2 ts = ImGui::CalcTextSize(indicators.c_str()); + float avail = ImGui::GetContentRegionAvail().x; + ImGui::SameLine(0, avail - ts.x - 4); + if (active) { + ImGui::TextColored(ImColor(120, 220, 120, 255), "%s", indicators.c_str()); + } else { + ImGui::TextColored(ImColor(220, 120, 120, 255), "%s", indicators.c_str()); + } + } + } + + // Title (2-3 lines) std::string t = iss.title; - if (t.size() > 70) t = t.substr(0, 67) + "..."; + if (t.size() > 140) t = t.substr(0, 137) + "..."; ImGui::TextWrapped("%s", t.c_str()); - // First domain chip + // Bottom: first domain chip if (!iss.domain.empty()) { - ImGui::TextColored(ImColor(140, 200, 255, 255), TI_TAG " %s", iss.domain[0].c_str()); + ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, iss.domain[0].c_str()); } ImGui::EndChild(); + ImGui::PopStyleColor(); - if (ImGui::IsItemClicked()) { + // Click → load detail + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { refresh_issue_detail(iss.id); } @@ -65,26 +108,106 @@ static void draw_card(const Issue& iss) { ImGui::EndDragDropSource(); } + // Right-click context menu + if (ImGui::BeginPopupContextItem("card_ctx")) { + if (iss.status == "bloqueado") { + if (ImGui::MenuItem(TI_LOCK_OPEN " Unblock (→ pendiente)")) { + patch_issue_status(iss.id, "pendiente"); + } + } else { + if (ImGui::MenuItem(TI_LOCK " Block this issue")) { + patch_issue_status(iss.id, "bloqueado"); + } + } + ImGui::Separator(); + if (ImGui::MenuItem(TI_PLAYER_PLAY " Launch agent (fix-issue)", nullptr, false, !is_agent_active(iss.id))) { + launch_agent(iss.id); + } + if (is_agent_active(iss.id)) { + ImGui::TextDisabled(TI_ROBOT " agent already running"); + } + ImGui::Separator(); + if (ImGui::MenuItem("Move → ideas")) patch_issue_status(iss.id, "ideas"); + if (ImGui::MenuItem("Move → pendiente")) patch_issue_status(iss.id, "pendiente"); + if (ImGui::MenuItem("Move → in-progress")) patch_issue_status(iss.id, "in-progress"); + if (ImGui::MenuItem("Move → completado")) patch_issue_status(iss.id, "completado"); + ImGui::EndPopup(); + } + ImGui::PopID(); } -static void draw_column(const char* status_key, std::vector& items) { +static void draw_flow_card(const Flow& fl, int child_issue_count) { + ImGui::PushID(("flow_" + fl.id).c_str()); + + const float card_h = 88.0f; + ImVec2 size(0.0f, card_h); + ImU32 bg = IM_COL32(80, 60, 110, 100); + ImGui::PushStyleColor(ImGuiCol_ChildBg, bg); + ImGui::BeginChild("flow_card", size, true, ImGuiWindowFlags_NoScrollbar); + + ImGui::TextColored(ImColor(190, 170, 230, 255), "%s flow %s", TI_GIT_BRANCH, fl.id.c_str()); + { + ImVec2 ts = ImGui::CalcTextSize("999 issues"); + float avail = ImGui::GetContentRegionAvail().x; + ImGui::SameLine(0, avail - ts.x - 4); + ImGui::TextDisabled("%d issues", child_issue_count); + } + + std::string t = fl.title; + if (t.size() > 140) t = t.substr(0, 137) + "..."; + ImGui::TextWrapped("%s", t.c_str()); + + if (!fl.tags.empty()) { + ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, fl.tags[0].c_str()); + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); + + // Click → set selected_issue_id to first child issue (best UX guess). + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + std::lock_guard g(state().mu); + for (const auto& iss : state().issues) { + if (iss.flow == fl.id) { + state().selected_issue_id = iss.id; + state().selected_issue_detail = iss; + break; + } + } + } + if (ImGui::BeginPopupContextItem("flow_ctx")) { + ImGui::TextDisabled("flow %s — %d issues", fl.id.c_str(), child_issue_count); + ImGui::Separator(); + ImGui::TextDisabled("(no actions; edit %s manually)", fl.file_path.c_str()); + ImGui::EndPopup(); + } + + ImGui::PopID(); +} + +static void draw_column(const char* status_key, const char* icon, + std::vector& issues, + std::vector>& flows) { ImGui::TableNextColumn(); - // Column header with count ImGui::PushFont(nullptr); - ImGui::TextColored(ImColor(220, 220, 220, 255), "%s (%zu)", status_key, items.size()); + size_t total = issues.size() + flows.size(); + ImGui::TextColored(ImColor(220, 220, 220, 255), "%s %s (%zu)", icon, status_key, total); ImGui::PopFont(); ImGui::Separator(); - // Drop target on the whole column ImVec2 region = ImGui::GetContentRegionAvail(); ImGui::BeginChild((std::string("col_") + status_key).c_str(), region, false); - for (const auto* iss : items) { + // Issues first (heavier visual), then flows (lighter). + for (const auto* iss : issues) { draw_card(*iss); } - // Bottom drop zone - ImGui::InvisibleButton("col_drop", ImVec2(-1, 16)); + for (const auto& [fl, cnt] : flows) { + draw_flow_card(*fl, cnt); + } + + ImGui::InvisibleButton("col_drop", ImVec2(-1, 24)); if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) { std::string id((const char*)p->Data, p->DataSize - 1); @@ -109,37 +232,84 @@ void draw_board() { return; } - // Snapshot under lock - std::vector snapshot; + std::vector issue_snap; + std::vector flow_snap; + bool agent_up = false; + int agent_running_count = 0; + std::string last_launch; + bool show_issues_flag = true; + bool show_flows_flag = true; { std::lock_guard g(state().mu); - snapshot = state().issues; + issue_snap = state().issues; + flow_snap = state().flows; + agent_up = state().agent_runner_up; + agent_running_count = (int)state().agent_active.size(); + last_launch = state().last_launch_msg; + show_issues_flag = state().filters.show_issues; + show_flows_flag = state().filters.show_flows; } - // Bucket issues by status - std::vector> buckets(k_n_columns); + // Bucket issues per column. + std::vector> issue_buckets(k_n_columns); int filtered_out = 0; - for (const auto& iss : snapshot) { - if (!passes_filters(iss)) { - filtered_out++; - continue; - } - for (int c = 0; c < k_n_columns; ++c) { - if (iss.status == k_columns[c]) { - buckets[c].push_back(&iss); - break; + if (show_issues_flag) { + for (const auto& iss : issue_snap) { + if (!passes_filters(iss)) { + filtered_out++; + continue; + } + std::string s = iss.status; + if (s == "bloqueado") s = "pendiente"; + for (int c = 0; c < k_n_columns; ++c) { + if (s == k_columns[c]) { + issue_buckets[c].push_back(&iss); + break; + } } } } - ImGui::Text("%zu total — %d filtered out", snapshot.size(), filtered_out); + // Count children per flow (issues that reference flow_id). + std::map flow_child_count; + for (const auto& iss : issue_snap) { + if (!iss.flow.empty()) flow_child_count[iss.flow]++; + } + + // Bucket flows per column using flow_column_index. + std::vector>> flow_buckets(k_n_columns); + if (show_flows_flag) { + for (const auto& fl : flow_snap) { + int col = flow_column_index(fl.status); + int cnt = flow_child_count.count(fl.id) ? flow_child_count[fl.id] : 0; + flow_buckets[col].push_back({&fl, cnt}); + } + } + + // Header line + size_t total_visible = 0; + for (int c = 0; c < k_n_columns; ++c) total_visible += issue_buckets[c].size() + flow_buckets[c].size(); + ImGui::Text("%zu issues + %zu flows — %d filtered out — visible %zu", + issue_snap.size(), flow_snap.size(), filtered_out, total_visible); + ImGui::SameLine(); + ImGui::TextDisabled(" | "); + ImGui::SameLine(); + if (agent_up) { + ImGui::TextColored(ImColor(120, 220, 120, 255), TI_ROBOT " agent_runner_api OK — %d active", agent_running_count); + } else { + ImGui::TextColored(ImColor(220, 140, 140, 255), TI_ROBOT " agent_runner_api offline"); + } + if (!last_launch.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled(" | %s", last_launch.c_str()); + } ImGui::Separator(); if (ImGui::BeginTable("board_table", k_n_columns, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame)) { ImGui::TableNextRow(); for (int c = 0; c < k_n_columns; ++c) { - draw_column(k_columns[c], buckets[c]); + draw_column(k_columns[c], k_column_icons[c], issue_buckets[c], flow_buckets[c]); } ImGui::EndTable(); } diff --git a/panel_filters.cpp b/panel_filters.cpp index 215e1a1..1bf79b7 100644 --- a/panel_filters.cpp +++ b/panel_filters.cpp @@ -44,6 +44,14 @@ void draw_filters() { domains = collect_domains(); tags = collect_tags(); + // Type toggle (Issues / Flows / Both). + ImGui::TextUnformatted("Show:"); + ImGui::SameLine(); + ImGui::Checkbox("Issues", &state().filters.show_issues); + ImGui::SameLine(); + ImGui::Checkbox("Flows", &state().filters.show_flows); + ImGui::Separator(); + multi_select("Priorities", meta.priorities, state().filters.priorities); multi_select("Scopes", meta.scopes, state().filters.scopes); multi_select("Domains", domains, state().filters.domains);