chore: auto-commit (8 archivos)
- backend/handlers.go - data.cpp - data.h - main.cpp - panel_board.cpp - panel_filters.cpp - appicon.ico - backend/kanban_cpp_backend.exe Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
+75
-1
@@ -1,8 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,6 +13,8 @@ import (
|
|||||||
"fn-registry/functions/infra"
|
"fn-registry/functions/infra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const agentRunnerBase = "http://127.0.0.1:8486"
|
||||||
|
|
||||||
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/api/health", s.handleHealth)
|
mux.HandleFunc("/api/health", s.handleHealth)
|
||||||
mux.HandleFunc("/api/issues", s.handleIssues)
|
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/flows/", s.handleFlowByID)
|
||||||
mux.HandleFunc("/api/meta", s.handleMeta)
|
mux.HandleFunc("/api/meta", s.handleMeta)
|
||||||
mux.HandleFunc("/api/sse", s.handleSSE)
|
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) {
|
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) {
|
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, 200, map[string]any{
|
writeJSON(w, 200, map[string]any{
|
||||||
"statuses": []string{"pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
|
"statuses": []string{"ideas", "pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
|
||||||
|
"board_columns": []string{"ideas", "pendiente", "in-progress", "completado"},
|
||||||
"priorities": []string{"critica", "alta", "media", "baja"},
|
"priorities": []string{"critica", "alta", "media", "baja"},
|
||||||
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
|
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
|
||||||
"types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"},
|
"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) {
|
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -86,6 +86,16 @@ static fn_http::Response http_patch_json(const std::string& path, const std::str
|
|||||||
return fn_http::request(req);
|
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() {
|
bool refresh_issues() {
|
||||||
auto resp = http_get("/api/issues");
|
auto resp = http_get("/api/issues");
|
||||||
if (resp.status != 200) {
|
if (resp.status != 200) {
|
||||||
@@ -179,6 +189,51 @@ bool patch_issue_status(const std::string& id, const std::string& new_status) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool refresh_agent_status() {
|
||||||
|
auto resp = http_get("/api/agent_status");
|
||||||
|
if (resp.status != 200) {
|
||||||
|
std::lock_guard<std::mutex> 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<std::string, std::string> 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<std::string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool up = j.value("available", false);
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> g(state().mu);
|
||||||
|
state().last_launch_msg = "launch failed (" + std::to_string(resp.status) + "): " + resp.body;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> 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) {
|
bool patch_issue_fields(const std::string& id, const std::string& json_partial) {
|
||||||
auto resp = http_patch_json("/api/issues/" + id, json_partial);
|
auto resp = http_patch_json("/api/issues/" + id, json_partial);
|
||||||
if (resp.status != 200) {
|
if (resp.status != 200) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -48,6 +49,8 @@ struct Filters {
|
|||||||
std::set<std::string> priorities;
|
std::set<std::string> priorities;
|
||||||
std::set<std::string> tags;
|
std::set<std::string> tags;
|
||||||
bool include_completed = false;
|
bool include_completed = false;
|
||||||
|
bool show_issues = true;
|
||||||
|
bool show_flows = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
@@ -62,6 +65,9 @@ struct State {
|
|||||||
bool loading = false;
|
bool loading = false;
|
||||||
std::string last_error;
|
std::string last_error;
|
||||||
long long last_refresh_ns = 0;
|
long long last_refresh_ns = 0;
|
||||||
|
std::map<std::string, std::string> agent_active; // issue_id -> run_id
|
||||||
|
bool agent_runner_up = false;
|
||||||
|
std::string last_launch_msg;
|
||||||
};
|
};
|
||||||
|
|
||||||
State& state();
|
State& state();
|
||||||
@@ -73,6 +79,9 @@ bool refresh_meta();
|
|||||||
bool refresh_issue_detail(const std::string& id);
|
bool refresh_issue_detail(const std::string& id);
|
||||||
bool patch_issue_status(const std::string& id, const std::string& new_status);
|
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 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).
|
// Background SSE subscription (no-op if already started).
|
||||||
void start_sse();
|
void start_sse();
|
||||||
|
|||||||
@@ -28,13 +28,20 @@ static void start_refresh_thread() {
|
|||||||
kanban::refresh_meta();
|
kanban::refresh_meta();
|
||||||
kanban::refresh_issues();
|
kanban::refresh_issues();
|
||||||
kanban::refresh_flows();
|
kanban::refresh_flows();
|
||||||
|
kanban::refresh_agent_status();
|
||||||
kanban::start_sse();
|
kanban::start_sse();
|
||||||
|
int tick = 0;
|
||||||
while (g_refresh_thread_alive) {
|
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;
|
if (!g_refresh_thread_alive) break;
|
||||||
|
kanban::refresh_agent_status();
|
||||||
|
if (++tick >= 10) {
|
||||||
|
tick = 0;
|
||||||
kanban::refresh_issues();
|
kanban::refresh_issues();
|
||||||
kanban::refresh_flows();
|
kanban::refresh_flows();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
kanban::stop_sse();
|
kanban::stop_sse();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-27
@@ -3,17 +3,29 @@
|
|||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
#include <imgui_internal.h>
|
#include <imgui_internal.h>
|
||||||
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/icons_tabler.h"
|
#include "core/icons_tabler.h"
|
||||||
|
|
||||||
namespace kanban {
|
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]);
|
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) {
|
static ImU32 priority_color(const std::string& p) {
|
||||||
if (p == "critica") return IM_COL32(255, 80, 80, 255);
|
if (p == "critica") return IM_COL32(255, 80, 80, 255);
|
||||||
if (p == "alta") return IM_COL32(255, 165, 0, 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);
|
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) {
|
static void draw_card(const Issue& iss) {
|
||||||
ImGui::PushID(iss.id.c_str());
|
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);
|
ImGui::BeginChild("card", size, true, ImGuiWindowFlags_NoScrollbar);
|
||||||
|
|
||||||
// Header: id + priority badge
|
// Top row: id · priority dot · agent icon (right-aligned) · block badge if blocked
|
||||||
ImGui::TextColored(ImColor(180, 180, 180, 255), "%s", iss.id.c_str());
|
ImGui::TextColored(ImColor(200, 200, 200, 255), "%s", iss.id.c_str());
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
{
|
{
|
||||||
ImVec2 cur = ImGui::GetCursorScreenPos();
|
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);
|
dl->AddCircleFilled(ImVec2(cur.x + r, cur.y + r + 2), r, c);
|
||||||
ImGui::Dummy(ImVec2(2 * r + 4, 2 * r));
|
ImGui::Dummy(ImVec2(2 * r + 4, 2 * r));
|
||||||
ImGui::SameLine();
|
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;
|
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());
|
ImGui::TextWrapped("%s", t.c_str());
|
||||||
|
|
||||||
// First domain chip
|
// Bottom: first domain chip
|
||||||
if (!iss.domain.empty()) {
|
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::EndChild();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
|
||||||
if (ImGui::IsItemClicked()) {
|
// Click → load detail
|
||||||
|
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
|
||||||
refresh_issue_detail(iss.id);
|
refresh_issue_detail(iss.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,26 +108,106 @@ static void draw_card(const Issue& iss) {
|
|||||||
ImGui::EndDragDropSource();
|
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();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void draw_column(const char* status_key, std::vector<const Issue*>& 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<std::mutex> 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<const Issue*>& issues,
|
||||||
|
std::vector<std::pair<const Flow*, int>>& flows) {
|
||||||
ImGui::TableNextColumn();
|
ImGui::TableNextColumn();
|
||||||
// Column header with count
|
|
||||||
ImGui::PushFont(nullptr);
|
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::PopFont();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
|
||||||
// Drop target on the whole column
|
|
||||||
ImVec2 region = ImGui::GetContentRegionAvail();
|
ImVec2 region = ImGui::GetContentRegionAvail();
|
||||||
ImGui::BeginChild((std::string("col_") + status_key).c_str(), region, false);
|
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);
|
draw_card(*iss);
|
||||||
}
|
}
|
||||||
// Bottom drop zone
|
for (const auto& [fl, cnt] : flows) {
|
||||||
ImGui::InvisibleButton("col_drop", ImVec2(-1, 16));
|
draw_flow_card(*fl, cnt);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::InvisibleButton("col_drop", ImVec2(-1, 24));
|
||||||
if (ImGui::BeginDragDropTarget()) {
|
if (ImGui::BeginDragDropTarget()) {
|
||||||
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) {
|
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) {
|
||||||
std::string id((const char*)p->Data, p->DataSize - 1);
|
std::string id((const char*)p->Data, p->DataSize - 1);
|
||||||
@@ -109,37 +232,84 @@ void draw_board() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot under lock
|
std::vector<Issue> issue_snap;
|
||||||
std::vector<Issue> snapshot;
|
std::vector<Flow> 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<std::mutex> g(state().mu);
|
std::lock_guard<std::mutex> 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
|
// Bucket issues per column.
|
||||||
std::vector<std::vector<const Issue*>> buckets(k_n_columns);
|
std::vector<std::vector<const Issue*>> issue_buckets(k_n_columns);
|
||||||
int filtered_out = 0;
|
int filtered_out = 0;
|
||||||
for (const auto& iss : snapshot) {
|
if (show_issues_flag) {
|
||||||
|
for (const auto& iss : issue_snap) {
|
||||||
if (!passes_filters(iss)) {
|
if (!passes_filters(iss)) {
|
||||||
filtered_out++;
|
filtered_out++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
std::string s = iss.status;
|
||||||
|
if (s == "bloqueado") s = "pendiente";
|
||||||
for (int c = 0; c < k_n_columns; ++c) {
|
for (int c = 0; c < k_n_columns; ++c) {
|
||||||
if (iss.status == k_columns[c]) {
|
if (s == k_columns[c]) {
|
||||||
buckets[c].push_back(&iss);
|
issue_buckets[c].push_back(&iss);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::Text("%zu total — %d filtered out", snapshot.size(), filtered_out);
|
// Count children per flow (issues that reference flow_id).
|
||||||
|
std::map<std::string, int> 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<std::vector<std::pair<const Flow*, int>>> 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();
|
ImGui::Separator();
|
||||||
|
|
||||||
if (ImGui::BeginTable("board_table", k_n_columns,
|
if (ImGui::BeginTable("board_table", k_n_columns,
|
||||||
ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame)) {
|
ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame)) {
|
||||||
ImGui::TableNextRow();
|
ImGui::TableNextRow();
|
||||||
for (int c = 0; c < k_n_columns; ++c) {
|
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();
|
ImGui::EndTable();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ void draw_filters() {
|
|||||||
domains = collect_domains();
|
domains = collect_domains();
|
||||||
tags = collect_tags();
|
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("Priorities", meta.priorities, state().filters.priorities);
|
||||||
multi_select("Scopes", meta.scopes, state().filters.scopes);
|
multi_select("Scopes", meta.scopes, state().filters.scopes);
|
||||||
multi_select("Domains", domains, state().filters.domains);
|
multi_select("Domains", domains, state().filters.domains);
|
||||||
|
|||||||
Reference in New Issue
Block a user