diff --git a/cpp/functions/viz/agent_runs_timeline.cpp b/cpp/functions/viz/agent_runs_timeline.cpp new file mode 100644 index 00000000..49aeae4d --- /dev/null +++ b/cpp/functions/viz/agent_runs_timeline.cpp @@ -0,0 +1,280 @@ +#include "viz/agent_runs_timeline.h" +#include "viz/agent_runs_timeline_helpers.h" +#include "core/icons_tabler.h" +#include "core/tokens.h" + +#include "imgui.h" + +#include +#include +#include +#include + +namespace fn_viz { + +namespace { + +// Map color token -> fn_tokens color. Mirrors timeline::status_color_token. +ImVec4 token_to_color(int t) { + using namespace fn_tokens::colors; + switch (t) { + case 1: return info; + case 2: return success; + case 3: return primary; + case 5: return warning; + case 6: return error; + case 0: + default: return text_muted; + } +} + +// Parse "#rrggbb" to ImVec4. On bad input returns text_muted. +ImVec4 parse_hex(const std::string& h) { + if (h.size() != 7 || h[0] != '#') return fn_tokens::colors::text_muted; + auto hex2 = [](const std::string& s, size_t i) -> int { + int v = 0; + for (int k = 0; k < 2; ++k) { + char c = s[i + k]; + int d = (c >= '0' && c <= '9') ? c - '0' + : (c >= 'a' && c <= 'f') ? 10 + c - 'a' + : (c >= 'A' && c <= 'F') ? 10 + c - 'A' + : -1; + if (d < 0) return -1; + v = v * 16 + d; + } + return v; + }; + int r = hex2(h, 1), g = hex2(h, 3), b = hex2(h, 5); + if (r < 0 || g < 0 || b < 0) return fn_tokens::colors::text_muted; + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, 1.0f); +} + +void draw_app_chip(const std::string& app) { + ImVec4 c = parse_hex(timeline::app_chip_hex(app)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(c.x, c.y, c.z, 0.20f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(c.x, c.y, c.z, 0.30f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(c.x, c.y, c.z, 0.40f)); + ImGui::PushStyleColor(ImGuiCol_Text, c); + ImGui::SmallButton(app.empty() ? "?" : app.c_str()); + ImGui::PopStyleColor(4); +} + +void draw_dod_badge(int done, int validated, int total) { + char buf[48]; + std::snprintf(buf, sizeof(buf), "%d/%d/%d", done, validated, total); + ImVec4 c = (validated >= total && total > 0) + ? fn_tokens::colors::success + : (done > 0 ? fn_tokens::colors::info : fn_tokens::colors::text_muted); + ImGui::TextColored(c, "%s", buf); +} + +std::string fmt_started(int64_t ts) { + if (ts <= 0) return "—"; + std::time_t t = (std::time_t)ts; + std::tm tm{}; +#if defined(_WIN32) + localtime_s(&tm, &t); +#else + localtime_r(&t, &tm); +#endif + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", &tm); + return std::string(buf); +} + +void draw_filters(TimelineState& state, + const std::vector& known_apps, + const std::vector& known_statuses) { + if (ImGui::Button(TI_FILTER " Apps")) ImGui::OpenPopup("##apps_filter"); + if (ImGui::BeginPopup("##apps_filter")) { + for (const auto& a : known_apps) { + bool sel = false; + for (const auto& s : state.filter.apps) if (s == a) { sel = true; break; } + if (ImGui::Checkbox(a.c_str(), &sel)) { + if (sel) state.filter.apps.push_back(a); + else { + state.filter.apps.erase( + std::remove(state.filter.apps.begin(), state.filter.apps.end(), a), + state.filter.apps.end()); + } + } + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + if (ImGui::Button(TI_FILTER " Status")) ImGui::OpenPopup("##status_filter"); + if (ImGui::BeginPopup("##status_filter")) { + for (const auto& s : known_statuses) { + bool sel = false; + for (const auto& x : state.filter.statuses) if (x == s) { sel = true; break; } + if (ImGui::Checkbox(s.c_str(), &sel)) { + if (sel) state.filter.statuses.push_back(s); + else { + state.filter.statuses.erase( + std::remove(state.filter.statuses.begin(), state.filter.statuses.end(), s), + state.filter.statuses.end()); + } + } + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + int days = (state.filter.since_ts == 0) + ? 0 + : (int)((std::time(nullptr) - state.filter.since_ts) / 86400); + if (ImGui::InputInt("Since (days)", &days, 1, 7)) { + if (days <= 0) state.filter.since_ts = 0; + else state.filter.since_ts = std::time(nullptr) - (int64_t)days * 86400; + } +} + +void draw_connection_badge(const std::string& status) { + ImVec4 c; + const char* sym; + if (status == "connected") { + c = fn_tokens::colors::success; + sym = "\xe2\x97\x8f"; // ● U+25CF + } else if (status == "connecting") { + c = fn_tokens::colors::warning; + sym = "\xe2\x97\x90"; // ◐ + } else { + c = fn_tokens::colors::error; + sym = "\xe2\x97\x8b"; // ○ U+25CB + } + ImGui::TextColored(c, "%s %s", sym, + status.empty() ? "disconnected" : status.c_str()); +} + +} // namespace + +void render_agent_runs_timeline(TimelineState& state) { + // Snapshot under the lock so the rest of the frame is lock-free. + std::vector runs_copy; + std::string conn_copy; + { + std::lock_guard lk(state.runs_mutex); + runs_copy = state.runs; + conn_copy = state.connection_status; + } + + // Build "known" sets from the runs themselves so the dropdowns reflect + // whatever the source emits, even if it grows beyond the docs list. + std::vector known_apps; + std::vector known_statuses; + { + std::unordered_map apps_seen, status_seen; + for (const auto& r : runs_copy) { + if (!r.app.empty() && !apps_seen[r.app]++) known_apps.push_back(r.app); + if (!r.status.empty() && !status_seen[r.status]++) known_statuses.push_back(r.status); + } + } + + draw_filters(state, known_apps, known_statuses); + ImGui::SameLine(); + ImGui::Dummy(ImVec2(16, 0)); + ImGui::SameLine(); + draw_connection_badge(conn_copy); + + ImGui::Separator(); + + auto rows = timeline::filter_and_sort(runs_copy, state.filter); + + if (ImGui::BeginTable("##agent_runs_table", 7, + ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f); + ImGui::TableSetupColumn("app", ImGuiTableColumnFlags_WidthFixed, 130.0f); + ImGui::TableSetupColumn("issue/card",ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("branch", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("dod", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("duration", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("started", ImGuiTableColumnFlags_WidthFixed, 130.0f); + ImGui::TableHeadersRow(); + + for (const auto& r : rows) { + ImGui::TableNextRow(); + + // Whole-row highlight + selectable via the first cell spanning. + ImGui::TableSetColumnIndex(0); + bool selected = (state.selected_run_id == r.id); + ImGui::PushID(r.id.c_str()); + + // Icon coloured by status. + ImVec4 c = token_to_color(timeline::status_color_token(r.status)); + ImGui::TextColored(c, "%s", timeline::status_icon_id(r.status).c_str()); + + // Make the row clickable: invisible button spanning all columns. + // Simpler — use Selectable on cell 1 with SpanAllColumns. + ImGui::TableSetColumnIndex(1); + char label[96]; + std::snprintf(label, sizeof(label), "##row_%s", r.id.c_str()); + if (ImGui::Selectable(label, selected, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + state.selected_run_id = r.id; + if (state.on_select) state.on_select(r.id); + } + ImGui::SameLine(); + draw_app_chip(r.app); + + ImGui::TableSetColumnIndex(2); + if (!r.issue_id.empty() && !r.card_id.empty()) { + ImGui::Text("%s / %s", r.issue_id.c_str(), r.card_id.c_str()); + } else if (!r.issue_id.empty()) { + ImGui::TextUnformatted(r.issue_id.c_str()); + } else { + ImGui::TextUnformatted(r.card_id.c_str()); + } + + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(r.branch.c_str()); + + ImGui::TableSetColumnIndex(4); + draw_dod_badge(r.dod_done, r.dod_validated, r.dod_total); + + ImGui::TableSetColumnIndex(5); + std::string dur = timeline::format_duration(r.started_at, r.finished_at); + ImGui::TextColored( + r.finished_at == 0 ? fn_tokens::colors::info : fn_tokens::colors::text, + "%s", dur.c_str()); + + ImGui::TableSetColumnIndex(6); + ImGui::TextUnformatted(fmt_started(r.started_at).c_str()); + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + + // Footer counts per status (over the FULL set, not the filtered one — gives + // a global sense even when filters narrow the table). + ImGui::Separator(); + std::unordered_map counts; + for (const auto& r : runs_copy) counts[r.status]++; + bool first = true; + for (const auto& s : {"pending", "running", "done", "validated", "merged", "aborted", "failed"}) { + int n = counts[s]; + if (!first) { ImGui::SameLine(); ImGui::TextUnformatted(" "); ImGui::SameLine(); } + first = false; + ImVec4 c = token_to_color(timeline::status_color_token(s)); + ImGui::TextColored(c, "%s: %d", s, n); + } +} + +void poll_sse_runs(TimelineState& state) { + // STUB. See header comment + agent_runs_timeline.md ## Gotchas. + // + // Intended future wiring: + // 1. spawn background thread with libcurl multi handle on state.sse_url + // - parse "data:" lines, json-decode AgentRun events + // - lock state.runs_mutex; upsert by run.id; set connection_status + // 2. OR call fn_core::http_request("GET", url + "?since=...", ...) + // every N seconds and full-refresh state.runs + // + // For now: noop so that consumers that wire poll_sse_runs() in their + // main loop don't crash. Consumers populate state.runs manually + // (fixtures, in-process orchestrator) until a real SSE issue lands. + (void)state; +} + +} // namespace fn_viz diff --git a/cpp/functions/viz/agent_runs_timeline.h b/cpp/functions/viz/agent_runs_timeline.h new file mode 100644 index 00000000..cfe14f47 --- /dev/null +++ b/cpp/functions/viz/agent_runs_timeline.h @@ -0,0 +1,82 @@ +#pragma once +// +// agent_runs_timeline.h — ImGui panel: cross-app timeline of agent runs. +// +// API surface declared per issue 0118. Render is impure (uses ImGui); pure +// helpers live in agent_runs_timeline_helpers.{h,cpp} so unit tests can +// exercise filter / sort / formatting without a GL context. +// +// Typical wiring (consumer side): +// +// fn_viz::TimelineState g_state; +// g_state.sse_url = "http://127.0.0.1:8497/api/runs/stream"; +// g_state.on_select = [](const std::string& id){ open_detail_panel(id); }; +// +// ImGui::Begin("Agent runs"); +// fn_viz::render_agent_runs_timeline(g_state); +// ImGui::End(); +// +// // Whenever a worker thread (real SSE client, polling fallback, fixture) +// // wants to update the list: +// { +// std::lock_guard lk(g_state.runs_mutex); +// g_state.runs = new_runs; +// g_state.connection_status = "connected"; +// } +// +// SSE client itself is OUT OF SCOPE for this issue — see the documented stub +// in poll_sse_runs() and the .md ## Gotchas section. + +#include +#include +#include +#include +#include + +namespace fn_viz { + +struct AgentRun { + std::string id; + std::string app; // kanban_cpp | skill_tree | ... + std::string issue_id; + std::string card_id; + std::string branch; + std::string status; // pending|running|done|validated|merged|aborted|failed + int64_t started_at = 0; // unix epoch seconds + int64_t finished_at = 0; // 0 if still running + int dod_total = 0; + int dod_done = 0; + int dod_validated = 0; +}; + +struct TimelineFilter { + std::vector apps; + std::vector statuses; + int64_t since_ts = 0; // 0 means "no lower bound" +}; + +struct TimelineState { + std::string sse_url; + std::vector runs; + TimelineFilter filter; + std::function on_select; + std::string selected_run_id; + std::string connection_status; // "connected"|"disconnected"|"connecting" + std::mutex runs_mutex; // SSE thread writes, UI reads +}; + +// Renders the timeline INLINE inside the current ImGui window. The caller +// owns ImGui::Begin/End — this function only draws filters, badge, table +// and footer counts. Safe to call every frame; cost is O(N) over runs. +void render_agent_runs_timeline(TimelineState& state); + +// STUB. Documented in agent_runs_timeline.md ## Gotchas. +// Today this is a no-op. The intent is that a future issue wires either: +// (a) a real SSE client (libcurl multi + custom read callback), or +// (b) a polling fallback that calls fn_core::http_request("GET", url, ...) +// every N seconds and replaces state.runs under runs_mutex. +// Until then, the consumer is responsible for populating state.runs (e.g. +// test fixtures, or an in-process orchestrator that owns the data already). +void poll_sse_runs(TimelineState& state); + +} // namespace fn_viz