From a1a7bf28a613e348ee4a0afd27fbfffa22db82d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 08:11:06 +0000 Subject: [PATCH 01/15] explore: Phoenix LiveView dashboard for real-time activity feed Scaffolds a Phoenix LiveView app under packages/phoenix-dashboard/ that reads the existing .htmlgraph/htmlgraph.db SQLite database and renders a real-time activity feed with: - Multi-level nesting: Session > UserQuery > Tool Events > Subagent Events (4 levels) - Color-coded badges for tool types, models, subagents, errors, features - Descending chronological order at every level - Live updates via GenServer polling (1s) + PubSub broadcast to LiveView - Expand/collapse per conversation turn and per nested event - Tree connectors for visual hierarchy - New event flash animation Key modules: - Activity: Hierarchical query builder against agent_events table - EventPoller: GenServer that polls SQLite and broadcasts via PubSub - Repo: Lightweight read-only SQLite access via exqlite - ActivityFeedLive: LiveView with recursive event_row component https://claude.ai/code/session_01R5FpDt5NCEkgVdXJSfddQ4 --- packages/phoenix-dashboard/.formatter.exs | 5 ++ packages/phoenix-dashboard/.gitignore | 16 ++++ packages/phoenix-dashboard/README.md | 81 +++++++++++++++++++ packages/phoenix-dashboard/assets/js/app.js | 15 ++++ packages/phoenix-dashboard/config/config.exs | 21 +++++ packages/phoenix-dashboard/config/dev.exs | 13 +++ packages/phoenix-dashboard/config/prod.exs | 6 ++ packages/phoenix-dashboard/config/runtime.exs | 21 +++++ packages/phoenix-dashboard/mix.exs | 45 +++++++++++ 9 files changed, 223 insertions(+) create mode 100644 packages/phoenix-dashboard/.formatter.exs create mode 100644 packages/phoenix-dashboard/.gitignore create mode 100644 packages/phoenix-dashboard/README.md create mode 100644 packages/phoenix-dashboard/assets/js/app.js create mode 100644 packages/phoenix-dashboard/config/config.exs create mode 100644 packages/phoenix-dashboard/config/dev.exs create mode 100644 packages/phoenix-dashboard/config/prod.exs create mode 100644 packages/phoenix-dashboard/config/runtime.exs create mode 100644 packages/phoenix-dashboard/mix.exs diff --git a/packages/phoenix-dashboard/.formatter.exs b/packages/phoenix-dashboard/.formatter.exs new file mode 100644 index 00000000..e945e12b --- /dev/null +++ b/packages/phoenix-dashboard/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/packages/phoenix-dashboard/.gitignore b/packages/phoenix-dashboard/.gitignore new file mode 100644 index 00000000..9083ad54 --- /dev/null +++ b/packages/phoenix-dashboard/.gitignore @@ -0,0 +1,16 @@ +# Elixir/Phoenix +/_build/ +/deps/ +/doc/ +/.fetch +erl_crash.dump +*.ez +*.beam + +# Node/Assets +/assets/node_modules/ +/priv/static/assets/ + +# Generated +*.swp +*~ diff --git a/packages/phoenix-dashboard/README.md b/packages/phoenix-dashboard/README.md new file mode 100644 index 00000000..c8cdd180 --- /dev/null +++ b/packages/phoenix-dashboard/README.md @@ -0,0 +1,81 @@ +# HtmlGraph Phoenix Dashboard + +**Exploration: Phoenix LiveView dashboard for HtmlGraph activity feed.** + +This is an exploratory implementation of a real-time activity feed dashboard +built with Phoenix LiveView, replacing the static HTML dashboard with live +WebSocket-driven updates. + +## Architecture + +``` + ┌─────────────────────────┐ + │ Phoenix LiveView App │ + │ │ +┌──────────┐ │ ┌───────────────────┐ │ ┌──────────┐ +│ Claude │──────▶│ │ EventPoller │ │◀──────│ Browser │ +│ Hooks │ │ │ (GenServer) │ │ WS │ Client │ +│ (Python) │ │ └────────┬──────────┘ │ └──────────┘ +└──────────┘ │ │ PubSub │ + │ │ ┌────────▼──────────┐ │ + │ │ │ ActivityFeedLive │ │ + ▼ │ │ (LiveView) │ │ +┌──────────┐ │ └───────────────────┘ │ +│ SQLite │◀──────│ │ +│ .htmlgraph/ │ exqlite (read-only) │ +│ htmlgraph.db └─────────────────────────┘ +└──────────┘ +``` + +## Key Features + +- **Multi-level nesting**: Session → UserQuery → Tool Events → Subagent Events (up to 4 levels) +- **Badges**: Color-coded tool types, models, subagent types, error states, feature links +- **Descending order**: Most recent events first at every level +- **Live updates**: 1-second polling with PubSub broadcast, new events flash green +- **Expand/collapse**: Per-turn and per-event toggle with tree connectors + +## Event Hierarchy + +``` +Session (collapsible group) +└── UserQuery "Fix the database schema" [15 tools] [2.3s] [Opus 4.6] + ├── Read src/schema.py [0.1s] + ├── Edit src/schema.py [0.2s] + ├── Task → researcher-agent [Haiku 4.5] (3) + │ ├── Read docs/api.md + │ ├── Grep "schema" + │ └── WebSearch "SQLite migrations" + ├── Bash "uv run pytest" [1.2s] + └── Write src/migration.py [0.1s] +``` + +## Running (once dependencies are available) + +```bash +cd packages/phoenix-dashboard +mix deps.get +mix phx.server +# Visit http://localhost:4000 +``` + +## Environment Variables + +- `HTMLGRAPH_DB_PATH` — Path to the HtmlGraph SQLite database (default: `../../.htmlgraph/htmlgraph.db`) +- `SECRET_KEY_BASE` — Required in production +- `PORT` — HTTP port (default: 4000) + +## Future: Pythonx Integration + +When Pythonx is added, the dashboard can call HtmlGraph's Python SDK directly: + +```elixir +# Instead of raw SQL queries, call the Python SDK +{:ok, result} = Pythonx.eval(""" +from htmlgraph import SDK +sdk = SDK(agent="phoenix-dashboard") +return sdk.analytics.recommend_next_work() +""") +``` + +This enables using all existing Python analytics without porting them to Elixir. diff --git a/packages/phoenix-dashboard/assets/js/app.js b/packages/phoenix-dashboard/assets/js/app.js new file mode 100644 index 00000000..cc0f1fb8 --- /dev/null +++ b/packages/phoenix-dashboard/assets/js/app.js @@ -0,0 +1,15 @@ +// Minimal LiveView client — connects WebSocket for live updates. +// No build tools needed; served as static JS. + +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + +let liveSocket = new LiveSocket("/live", Socket, { + params: {_csrf_token: csrfToken} +}) + +liveSocket.connect() + +window.liveSocket = liveSocket diff --git a/packages/phoenix-dashboard/config/config.exs b/packages/phoenix-dashboard/config/config.exs new file mode 100644 index 00000000..d7175d5b --- /dev/null +++ b/packages/phoenix-dashboard/config/config.exs @@ -0,0 +1,21 @@ +import Config + +config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + url: [host: "localhost"], + render_errors: [ + formats: [html: HtmlgraphDashboardWeb.ErrorHTML], + layout: false + ], + pubsub_server: HtmlgraphDashboard.PubSub, + live_view: [signing_salt: "htmlgraph_lv"] + +config :htmlgraph_dashboard, + db_path: System.get_env("HTMLGRAPH_DB_PATH") || "../../.htmlgraph/htmlgraph.db" + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :phoenix, :json_library, Jason + +import_config "#{config_env()}.exs" diff --git a/packages/phoenix-dashboard/config/dev.exs b/packages/phoenix-dashboard/config/dev.exs new file mode 100644 index 00000000..ac3db2ba --- /dev/null +++ b/packages/phoenix-dashboard/config/dev.exs @@ -0,0 +1,13 @@ +import Config + +config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "dev-only-secret-key-base-that-is-at-least-64-bytes-long-for-development-use", + watchers: [] + +config :logger, :console, format: "[$level] $message\n" +config :phoenix, :stacktrace_depth, 20 +config :phoenix, :plug_init_mode, :runtime diff --git a/packages/phoenix-dashboard/config/prod.exs b/packages/phoenix-dashboard/config/prod.exs new file mode 100644 index 00000000..ab36941b --- /dev/null +++ b/packages/phoenix-dashboard/config/prod.exs @@ -0,0 +1,6 @@ +import Config + +config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +config :logger, level: :info diff --git a/packages/phoenix-dashboard/config/runtime.exs b/packages/phoenix-dashboard/config/runtime.exs new file mode 100644 index 00000000..c1a4008c --- /dev/null +++ b/packages/phoenix-dashboard/config/runtime.exs @@ -0,0 +1,21 @@ +import Config + +if config_env() == :prod do + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "localhost" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ip: {0, 0, 0, 0}, port: port], + secret_key_base: secret_key_base +end + +config :htmlgraph_dashboard, + db_path: System.get_env("HTMLGRAPH_DB_PATH") || "../../.htmlgraph/htmlgraph.db" diff --git a/packages/phoenix-dashboard/mix.exs b/packages/phoenix-dashboard/mix.exs new file mode 100644 index 00000000..f4a10036 --- /dev/null +++ b/packages/phoenix-dashboard/mix.exs @@ -0,0 +1,45 @@ +defmodule HtmlgraphDashboard.MixProject do + use Mix.Project + + def project do + [ + app: :htmlgraph_dashboard, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases() + ] + end + + def application do + [ + mod: {HtmlgraphDashboard.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp deps do + [ + {:phoenix, "~> 1.7.0"}, + {:phoenix_html, "~> 3.3"}, + {:phoenix_live_view, "~> 0.20.0"}, + {:phoenix_live_reload, "~> 1.4", only: :dev}, + {:phoenix_live_dashboard, "~> 0.8.0"}, + {:exqlite, "~> 0.13"}, + {:jason, "~> 1.4"}, + {:plug_cowboy, "~> 2.6"}, + {:esbuild, "~> 0.7", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} + ] + end + + defp aliases do + [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind default", "esbuild default"], + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] + ] + end +end From ce8e7be5ce83adfadec8af785d3fbb611c29e265 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 08:11:56 +0000 Subject: [PATCH 02/15] explore: add Phoenix dashboard lib/ source files Force-add Elixir source files that were excluded by root .gitignore lib/ rule. Includes all application modules: - Activity: hierarchical event query builder - Repo: read-only SQLite access via exqlite - EventPoller: GenServer polling + PubSub broadcast - ActivityFeedLive: LiveView with recursive nested events - Styles: inline CSS (dark theme, badges, tree connectors) - Layouts, Router, Endpoint, CoreComponents https://claude.ai/code/session_01R5FpDt5NCEkgVdXJSfddQ4 --- .../lib/htmlgraph_dashboard/activity.ex | 213 ++++++++ .../lib/htmlgraph_dashboard/application.ex | 22 + .../lib/htmlgraph_dashboard/event_poller.ex | 89 ++++ .../lib/htmlgraph_dashboard/repo.ex | 119 +++++ .../lib/htmlgraph_dashboard_web.ex | 89 ++++ .../components/core_components.ex | 19 + .../components/layouts.ex | 5 + .../components/layouts/app.html.heex | 4 + .../components/layouts/root.html.heex | 16 + .../lib/htmlgraph_dashboard_web/endpoint.ex | 32 ++ .../lib/htmlgraph_dashboard_web/error_html.ex | 7 + .../live/activity_feed_live.ex | 475 ++++++++++++++++++ .../lib/htmlgraph_dashboard_web/router.ex | 22 + .../lib/htmlgraph_dashboard_web/styles.ex | 423 ++++++++++++++++ 14 files changed, 1535 insertions(+) create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard/application.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard/event_poller.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard/repo.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/core_components.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/app.html.heex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/root.html.heex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/endpoint.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/error_html.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/router.ex create mode 100644 packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex new file mode 100644 index 00000000..3bb75b6c --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex @@ -0,0 +1,213 @@ +defmodule HtmlgraphDashboard.Activity do + @moduledoc """ + Queries and structures the activity feed data from the HtmlGraph database. + + Builds a multi-level nested tree: + Session → UserQuery (conversation turn) → Tool events → Subagent events + """ + + alias HtmlgraphDashboard.Repo + + @max_depth 4 + + @doc """ + Fetch recent conversation turns with nested children, grouped by session. + Returns a list of session groups, each containing conversation turns. + """ + def list_activity_feed(opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + session_id = Keyword.get(opts, :session_id, nil) + + # Fetch UserQuery events (conversation turns) — these are the top-level entries + user_queries = fetch_user_queries(limit, session_id) + + # For each UserQuery, recursively fetch children + turns = + Enum.map(user_queries, fn uq -> + children = fetch_children(uq["event_id"], 0) + stats = compute_stats(children) + + work_item = if uq["feature_id"], do: fetch_feature(uq["feature_id"]), else: nil + + %{ + user_query: uq, + children: children, + stats: stats, + work_item: work_item + } + end) + + # Group by session + turns + |> Enum.group_by(fn t -> t.user_query["session_id"] end) + |> Enum.map(fn {session_id, session_turns} -> + session = fetch_session(session_id) + + %{ + session_id: session_id, + session: session, + turns: session_turns + } + end) + |> Enum.sort_by( + fn group -> + case group.turns do + [first | _] -> first.user_query["timestamp"] + [] -> "" + end + end, + :desc + ) + end + + @doc """ + Fetch a single event by ID with its full subtree. + """ + def get_event_tree(event_id) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE event_id = ? + """ + + case Repo.query_maps(sql, [event_id]) do + {:ok, [event]} -> + children = fetch_children(event_id, 0) + {:ok, Map.put(event, "children", children)} + + {:ok, []} -> + {:error, :not_found} + + {:error, reason} -> + {:error, reason} + end + end + + # --- Private --- + + defp fetch_user_queries(limit, nil) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE tool_name = 'UserQuery' + ORDER BY timestamp DESC + LIMIT ? + """ + + case Repo.query_maps(sql, [limit]) do + {:ok, rows} -> rows + {:error, _} -> [] + end + end + + defp fetch_user_queries(limit, session_id) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE tool_name = 'UserQuery' AND session_id = ? + ORDER BY timestamp DESC + LIMIT ? + """ + + case Repo.query_maps(sql, [session_id, limit]) do + {:ok, rows} -> rows + {:error, _} -> [] + end + end + + defp fetch_children(_parent_id, depth) when depth >= @max_depth, do: [] + + defp fetch_children(parent_id, depth) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE parent_event_id = ? + AND NOT (tool_name = 'Agent' AND event_type != 'task_delegation') + ORDER BY timestamp DESC + """ + + case Repo.query_maps(sql, [parent_id]) do + {:ok, rows} -> + Enum.map(rows, fn row -> + grandchildren = fetch_children(row["event_id"], depth + 1) + + row + |> Map.put("children", grandchildren) + |> Map.put("depth", depth) + end) + + {:error, _} -> + [] + end + end + + defp compute_stats(children) do + flat = flatten_children(children) + + %{ + tool_count: length(flat), + total_duration: + flat + |> Enum.map(fn c -> (c["execution_duration_seconds"] || 0) end) + |> Enum.sum() + |> Float.round(2), + success_count: Enum.count(flat, fn c -> c["status"] in ["recorded", "success", "completed"] end), + error_count: Enum.count(flat, fn c -> c["event_type"] == "error" end), + models: flat |> Enum.map(fn c -> c["model"] end) |> Enum.reject(&is_nil/1) |> Enum.uniq(), + total_tokens: + flat + |> Enum.map(fn c -> (c["cost_tokens"] || 0) end) + |> Enum.sum() + } + end + + defp flatten_children(children) do + Enum.flat_map(children, fn child -> + [child | flatten_children(child["children"] || [])] + end) + end + + defp fetch_session(nil), do: nil + + defp fetch_session(session_id) do + sql = """ + SELECT session_id, agent_assigned, status, created_at, completed_at, + total_events, total_tokens_used, is_subagent, last_user_query, + model + FROM sessions + WHERE session_id = ? + """ + + case Repo.query_maps(sql, [session_id]) do + {:ok, [session]} -> session + _ -> nil + end + end + + defp fetch_feature(nil), do: nil + + defp fetch_feature(feature_id) do + sql = """ + SELECT id, type, title, status, priority + FROM features + WHERE id = ? + """ + + case Repo.query_maps(sql, [feature_id]) do + {:ok, [feature]} -> feature + _ -> nil + end + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/application.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/application.ex new file mode 100644 index 00000000..c50847b3 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/application.ex @@ -0,0 +1,22 @@ +defmodule HtmlgraphDashboard.Application do + @moduledoc false + use Application + + @impl true + def start(_type, _args) do + children = [ + {Phoenix.PubSub, name: HtmlgraphDashboard.PubSub}, + HtmlgraphDashboardWeb.Endpoint, + {HtmlgraphDashboard.EventPoller, []} + ] + + opts = [strategy: :one_for_one, name: HtmlgraphDashboard.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + HtmlgraphDashboardWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/event_poller.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/event_poller.ex new file mode 100644 index 00000000..5723b179 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/event_poller.ex @@ -0,0 +1,89 @@ +defmodule HtmlgraphDashboard.EventPoller do + @moduledoc """ + Polls the HtmlGraph SQLite database for new events and broadcasts + them via Phoenix PubSub for live updates. + + Checks every 1 second for new events since last poll. + """ + use GenServer + + alias HtmlgraphDashboard.Repo + + @poll_interval_ms 1_000 + @topic "activity_feed" + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Subscribe to live event updates." + def subscribe do + Phoenix.PubSub.subscribe(HtmlgraphDashboard.PubSub, @topic) + end + + @impl true + def init(_opts) do + state = %{ + last_event_id: nil, + last_timestamp: nil + } + + schedule_poll() + {:ok, state} + end + + @impl true + def handle_info(:poll, state) do + new_state = poll_new_events(state) + schedule_poll() + {:noreply, new_state} + end + + defp schedule_poll do + Process.send_after(self(), :poll, @poll_interval_ms) + end + + defp poll_new_events(%{last_timestamp: nil} = state) do + # First poll — just record the latest timestamp, don't broadcast history + case Repo.query("SELECT timestamp FROM agent_events ORDER BY timestamp DESC LIMIT 1") do + {:ok, [[ts]]} -> + %{state | last_timestamp: ts} + + _ -> + state + end + end + + defp poll_new_events(%{last_timestamp: last_ts} = state) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE timestamp > ? + ORDER BY timestamp ASC + LIMIT 100 + """ + + case Repo.query_maps(sql, [last_ts]) do + {:ok, []} -> + state + + {:ok, events} -> + Enum.each(events, fn event -> + Phoenix.PubSub.broadcast( + HtmlgraphDashboard.PubSub, + @topic, + {:new_event, event} + ) + end) + + latest = List.last(events) + %{state | last_timestamp: latest["timestamp"]} + + {:error, _reason} -> + state + end + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/repo.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/repo.ex new file mode 100644 index 00000000..fd1bf1f5 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/repo.ex @@ -0,0 +1,119 @@ +defmodule HtmlgraphDashboard.Repo do + @moduledoc """ + Direct SQLite3 reader for the HtmlGraph database. + + Read-only access to the existing .htmlgraph/htmlgraph.db file. + Uses exqlite for lightweight SQLite3 connectivity. + """ + + @doc """ + Returns the configured database path, resolved relative to the app root. + """ + def db_path do + path = Application.get_env(:htmlgraph_dashboard, :db_path, "../../.htmlgraph/htmlgraph.db") + + if Path.type(path) == :relative do + Path.join(Application.app_dir(:htmlgraph_dashboard, ""), path) + |> Path.expand() + else + path + end + end + + @doc """ + Execute a read-only query against the HtmlGraph database. + Returns {:ok, rows} or {:error, reason}. + """ + def query(sql, params \\ []) do + path = db_path() + + case Exqlite.Sqlite3.open(path, [:readonly]) do + {:ok, conn} -> + try do + execute_query(conn, sql, params) + after + Exqlite.Sqlite3.close(conn) + end + + {:error, reason} -> + {:error, {:open_failed, reason, path}} + end + end + + @doc """ + Execute a query and return rows as maps with column name keys. + """ + def query_maps(sql, params \\ []) do + path = db_path() + + case Exqlite.Sqlite3.open(path, [:readonly]) do + {:ok, conn} -> + try do + case Exqlite.Sqlite3.prepare(conn, sql) do + {:ok, stmt} -> + bind_params(conn, stmt, params) + rows = collect_rows(conn, stmt) + columns = get_columns(conn, stmt) + Exqlite.Sqlite3.release(conn, stmt) + + maps = + Enum.map(rows, fn row -> + columns + |> Enum.zip(row) + |> Map.new() + end) + + {:ok, maps} + + {:error, reason} -> + {:error, reason} + end + after + Exqlite.Sqlite3.close(conn) + end + + {:error, reason} -> + {:error, {:open_failed, reason, path}} + end + end + + defp execute_query(conn, sql, params) do + case Exqlite.Sqlite3.prepare(conn, sql) do + {:ok, stmt} -> + bind_params(conn, stmt, params) + rows = collect_rows(conn, stmt) + Exqlite.Sqlite3.release(conn, stmt) + {:ok, rows} + + {:error, reason} -> + {:error, reason} + end + end + + defp bind_params(_conn, _stmt, []), do: :ok + + defp bind_params(conn, stmt, params) do + Exqlite.Sqlite3.bind(conn, stmt, params) + end + + defp collect_rows(conn, stmt) do + collect_rows(conn, stmt, []) + end + + defp collect_rows(conn, stmt, acc) do + case Exqlite.Sqlite3.step(conn, stmt) do + {:row, row} -> collect_rows(conn, stmt, [row | acc]) + :done -> Enum.reverse(acc) + {:error, _reason} -> Enum.reverse(acc) + end + end + + defp get_columns(conn, stmt) do + count = Exqlite.Sqlite3.column_count(conn, stmt) + + Enum.map(0..(count - 1), fn i -> + {:ok, name} = Exqlite.Sqlite3.column_name(conn, stmt, i) + name + end) + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web.ex new file mode 100644 index 00000000..914b4c8c --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web.ex @@ -0,0 +1,89 @@ +defmodule HtmlgraphDashboardWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: HtmlgraphDashboardWeb.Layouts] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {HtmlgraphDashboardWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + import Phoenix.LiveView.Helpers + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: HtmlgraphDashboardWeb.Endpoint, + router: HtmlgraphDashboardWeb.Router, + statics: HtmlgraphDashboardWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/core_components.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/core_components.ex new file mode 100644 index 00000000..305b7f8b --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/core_components.ex @@ -0,0 +1,19 @@ +defmodule HtmlgraphDashboardWeb.CoreComponents do + @moduledoc """ + Minimal core components for the dashboard. + """ + use Phoenix.Component + + def flash_group(assigns) do + ~H""" +
+

+ <%= Phoenix.Flash.get(@flash, :info) %> +

+

+ <%= Phoenix.Flash.get(@flash, :error) %> +

+
+ """ + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts.ex new file mode 100644 index 00000000..109642e0 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts.ex @@ -0,0 +1,5 @@ +defmodule HtmlgraphDashboardWeb.Layouts do + use HtmlgraphDashboardWeb, :html + + embed_templates "layouts/*" +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/app.html.heex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/app.html.heex new file mode 100644 index 00000000..a9efe869 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/app.html.heex @@ -0,0 +1,4 @@ +
+ <.flash_group flash={@flash} /> + <%= @inner_content %> +
diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/root.html.heex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/root.html.heex new file mode 100644 index 00000000..cee32ab0 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/root.html.heex @@ -0,0 +1,16 @@ + + + + + + + HtmlGraph — Activity Feed + + + + + <%= @inner_content %> + + diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/endpoint.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/endpoint.ex new file mode 100644 index 00000000..58ebf851 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/endpoint.ex @@ -0,0 +1,32 @@ +defmodule HtmlgraphDashboardWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :htmlgraph_dashboard + + @session_options [ + store: :cookie, + key: "_htmlgraph_dashboard_key", + signing_salt: "htmlgraph_salt", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]] + + plug Plug.Static, + at: "/", + from: :htmlgraph_dashboard, + gzip: false, + only: HtmlgraphDashboardWeb.static_paths() + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug HtmlgraphDashboardWeb.Router +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/error_html.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/error_html.ex new file mode 100644 index 00000000..6465808b --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/error_html.ex @@ -0,0 +1,7 @@ +defmodule HtmlgraphDashboardWeb.ErrorHTML do + use HtmlgraphDashboardWeb, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex new file mode 100644 index 00000000..267ac550 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex @@ -0,0 +1,475 @@ +defmodule HtmlgraphDashboardWeb.ActivityFeedLive do + @moduledoc """ + Live activity feed with multi-level nested events, badges, and real-time updates. + + Architecture: + - Polls SQLite database via EventPoller GenServer + - Receives new events via PubSub broadcast + - Maintains expand/collapse state per conversation turn + - Multi-level nesting: Session > UserQuery > Tool Events > Subagent Events + """ + use HtmlgraphDashboardWeb, :live_view + + alias HtmlgraphDashboard.Activity + alias HtmlgraphDashboard.EventPoller + + @impl true + def mount(params, _session, socket) do + if connected?(socket) do + EventPoller.subscribe() + end + + session_id = params["session_id"] + + socket = + socket + |> assign(:session_filter, session_id) + |> assign(:expanded, MapSet.new()) + |> assign(:new_event_ids, MapSet.new()) + |> load_feed() + + {:ok, socket} + end + + @impl true + def handle_params(params, _uri, socket) do + session_id = params["session_id"] + + socket = + socket + |> assign(:session_filter, session_id) + |> load_feed() + + {:noreply, socket} + end + + @impl true + def handle_event("toggle", %{"event-id" => event_id}, socket) do + expanded = socket.assigns.expanded + + expanded = + if MapSet.member?(expanded, event_id) do + MapSet.delete(expanded, event_id) + else + MapSet.put(expanded, event_id) + end + + {:noreply, assign(socket, :expanded, expanded)} + end + + def handle_event("toggle_session", %{"session-id" => session_id}, socket) do + expanded = socket.assigns.expanded + key = "session:#{session_id}" + + expanded = + if MapSet.member?(expanded, key) do + MapSet.delete(expanded, key) + else + MapSet.put(expanded, key) + end + + {:noreply, assign(socket, :expanded, expanded)} + end + + @impl true + def handle_info({:new_event, event}, socket) do + # Mark the new event for flash animation + new_ids = MapSet.put(socket.assigns.new_event_ids, event["event_id"]) + + socket = + socket + |> assign(:new_event_ids, new_ids) + |> load_feed() + + # Clear the flash after 3 seconds + Process.send_after(self(), {:clear_new, event["event_id"]}, 3_000) + + {:noreply, socket} + end + + def handle_info({:clear_new, event_id}, socket) do + new_ids = MapSet.delete(socket.assigns.new_event_ids, event_id) + {:noreply, assign(socket, :new_event_ids, new_ids)} + end + + defp load_feed(socket) do + opts = + case socket.assigns[:session_filter] do + nil -> [limit: 50] + sid -> [limit: 50, session_id: sid] + end + + feed = Activity.list_activity_feed(opts) + total_events = feed |> Enum.map(fn g -> length(g.turns) end) |> Enum.sum() + + socket + |> assign(:feed, feed) + |> assign(:total_events, total_events) + end + + # --- Template Helpers --- + + defp tool_badge_class(tool_name) do + case tool_name do + "UserQuery" -> "badge-userquery" + "Task" -> "badge-task" + "Agent" -> "badge-agent" + "Bash" -> "badge-tool" + "Read" -> "badge-tool" + "Write" -> "badge-tool" + "Edit" -> "badge-tool" + "Glob" -> "badge-tool" + "Grep" -> "badge-tool" + _ -> "badge-tool" + end + end + + defp event_dot_class(event_type) do + case event_type do + "error" -> "error" + "task_delegation" -> "task_delegation" + "delegation" -> "delegation" + "tool_result" -> "tool_result" + _ -> "tool_call" + end + end + + defp format_timestamp(nil), do: "" + + defp format_timestamp(ts) when is_binary(ts) do + # Extract HH:MM:SS from ISO timestamp + case Regex.run(~r/(\d{2}:\d{2}:\d{2})/, ts) do + [_, time] -> time + _ -> ts + end + end + + defp format_duration(nil), do: "" + defp format_duration(0), do: "" + defp format_duration(0.0), do: "" + + defp format_duration(seconds) when is_number(seconds) do + cond do + seconds < 1 -> "#{round(seconds * 1000)}ms" + seconds < 60 -> "#{Float.round(seconds * 1.0, 1)}s" + true -> "#{round(seconds / 60)}m" + end + end + + defp format_tokens(nil), do: "" + defp format_tokens(0), do: "" + defp format_tokens(tokens) when tokens > 1000, do: "#{Float.round(tokens / 1000, 1)}k" + defp format_tokens(tokens), do: "#{tokens}" + + defp truncate(nil, _), do: "" + + defp truncate(text, max_len) when is_binary(text) do + if String.length(text) > max_len do + String.slice(text, 0, max_len) <> "..." + else + text + end + end + + defp has_children?(event) do + children = event["children"] || [] + length(children) > 0 + end + + defp child_count(event) do + children = event["children"] || [] + length(children) + end + + defp is_expanded?(expanded, event_id) do + MapSet.member?(expanded, event_id) + end + + defp is_new?(new_event_ids, event_id) do + MapSet.member?(new_event_ids, event_id) + end + + defp session_expanded?(expanded, session_id) do + MapSet.member?(expanded, "session:#{session_id}") + end + + defp depth_class(depth) do + case depth do + 0 -> "depth-0" + 1 -> "depth-1" + 2 -> "depth-2" + _ -> "depth-3" + end + end + + defp tree_connector(depth) do + case depth do + 0 -> "├─" + 1 -> "│ ├─" + 2 -> "│ │ ├─" + _ -> "│ │ │ ├─" + end + end + + @impl true + def render(assigns) do + ~H""" +
+
+ + HtmlGraph Activity Feed +
+
+
+ + Live +
+
+ <%= @total_events %> conversation turns +
+
+
+ +
+ <%= if @feed == [] do %> +
+

No activity yet

+

Events will appear here as agents work. The feed updates in real-time.

+
+ <% else %> + <%= for group <- @feed do %> +
+ +
+
+ + + ▶ + + + + <%= truncate(group.session_id, 12) %> + + <%= if group.session do %> + + <%= group.session["status"] || "active" %> + + <%= if group.session["agent_assigned"] do %> + + <%= group.session["agent_assigned"] %> + + <% end %> + <%= if group.session["model"] do %> + + <%= group.session["model"] %> + + <% end %> + <% end %> +
+
+ + <%= length(group.turns) %> turns + + <%= if group.session do %> + + <%= group.session["total_events"] || 0 %> events + + <% end %> +
+
+ + + + + + + + + + + + + + + + <%= for turn <- group.turns do %> + + + + + + + + + + + + + <%= if is_expanded?(@expanded, turn.user_query["event_id"]) do %> + <%= for child <- turn.children do %> + <.event_row + event={child} + expanded={@expanded} + new_event_ids={@new_event_ids} + /> + <% end %> + <% end %> + <% end %> + +
EventSummaryBadgesTimeDurationTokens
+ <%= if length(turn.children) > 0 do %> + + <% end %> + + + UserQuery + + + <%= truncate(turn.user_query["input_summary"], 100) %> + + +
+ + <%= turn.stats.tool_count %> tools + + <%= if turn.stats.error_count > 0 do %> + + <%= turn.stats.error_count %> errors + + <% end %> + <%= if turn.work_item do %> + + <%= truncate(turn.work_item["title"], 30) %> + + <% end %> + <%= for model <- turn.stats.models do %> + <%= model %> + <% end %> +
+
+ + <%= format_timestamp(turn.user_query["timestamp"]) %> + + + + <%= format_duration(turn.stats.total_duration) %> + + + + <%= format_tokens(turn.stats.total_tokens) %> + +
+
+ <% end %> + <% end %> +
+ """ + end + + defp event_row(assigns) do + ~H""" + + + <%= if has_children?(@event) do %> + + <% end %> + + +
+ + <%= tree_connector(@event["depth"] || 0) %> + + + + <%= @event["tool_name"] %> + + +
+ + + + <%= truncate(@event["input_summary"] || @event["output_summary"], 80) %> + + + +
+ <%= if @event["subagent_type"] do %> + + <%= @event["subagent_type"] %> + + <% end %> + <%= if @event["model"] do %> + + <%= @event["model"] %> + + <% end %> + <%= if @event["event_type"] == "error" do %> + error + <% end %> + <%= if has_children?(@event) do %> + + <%= child_count(@event) %> + + <% end %> +
+ + + + <%= format_timestamp(@event["timestamp"]) %> + + + + + <%= format_duration(@event["execution_duration_seconds"]) %> + + + + + <%= format_tokens(@event["cost_tokens"]) %> + + + + + + <%= if is_expanded?(@expanded, @event["event_id"]) do %> + <%= for child <- (@event["children"] || []) do %> + <.event_row + event={child} + expanded={@expanded} + new_event_ids={@new_event_ids} + /> + <% end %> + <% end %> + """ + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/router.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/router.ex new file mode 100644 index 00000000..31e9748b --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/router.ex @@ -0,0 +1,22 @@ +defmodule HtmlgraphDashboardWeb.Router do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.LiveView.Router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {HtmlgraphDashboardWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + scope "/", HtmlgraphDashboardWeb do + pipe_through :browser + + live "/", ActivityFeedLive, :index + live "/session/:session_id", ActivityFeedLive, :session + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex new file mode 100644 index 00000000..b84c270f --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex @@ -0,0 +1,423 @@ +defmodule HtmlgraphDashboardWeb.Styles do + @moduledoc "Inline CSS for the dashboard. No build tools needed." + + def css do + ~S""" + :root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-hover: #30363d; + --border: #30363d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-orange: #d29922; + --accent-red: #f85149; + --accent-purple: #bc8cff; + --accent-cyan: #39d2c0; + --accent-pink: #f778ba; + --radius: 6px; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + + body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + } + + /* Header */ + .header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; + } + + .header-title { + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + } + + .header-title .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + + .header-meta { + font-size: 12px; + color: var(--text-secondary); + } + + /* Activity Feed Container */ + .feed-container { + max-width: 1400px; + margin: 0 auto; + padding: 16px 24px; + } + + /* Session Group */ + .session-group { + margin-bottom: 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + } + + .session-header { + background: var(--bg-secondary); + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); + cursor: pointer; + } + + .session-header:hover { + background: var(--bg-tertiary); + } + + .session-info { + display: flex; + align-items: center; + gap: 12px; + } + + /* Activity Table */ + .activity-table { + width: 100%; + border-collapse: collapse; + } + + .activity-table th { + background: var(--bg-tertiary); + padding: 6px 12px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); + position: sticky; + top: 49px; + z-index: 10; + } + + /* Row styles */ + .activity-row { + border-bottom: 1px solid var(--border); + transition: background 0.15s; + } + + .activity-row:hover { + background: var(--bg-hover); + } + + .activity-row td { + padding: 6px 12px; + vertical-align: middle; + white-space: nowrap; + } + + /* Parent row (UserQuery) */ + .activity-row.parent-row { + background: var(--bg-secondary); + } + + .activity-row.parent-row:hover { + background: var(--bg-tertiary); + } + + .activity-row.parent-row td { + padding: 10px 12px; + font-weight: 500; + } + + /* Child rows — depth indentation */ + .activity-row.child-row { display: none; } + .activity-row.child-row.expanded { display: table-row; } + + .depth-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .depth-guide { + display: inline-block; + width: 20px; + border-left: 2px solid var(--border); + height: 100%; + margin-right: 0; + } + + .depth-0 .depth-indent { padding-left: 24px; } + .depth-1 .depth-indent { padding-left: 48px; } + .depth-2 .depth-indent { padding-left: 72px; } + .depth-3 .depth-indent { padding-left: 96px; } + + .tree-connector { + color: var(--text-muted); + margin-right: 6px; + font-family: var(--font-mono); + font-size: 12px; + } + + /* Toggle button */ + .toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + transition: all 0.15s; + display: inline-flex; + align-items: center; + } + + .toggle-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .toggle-btn .arrow { + display: inline-block; + transition: transform 0.2s; + } + + .toggle-btn .arrow.expanded { + transform: rotate(90deg); + } + + /* Badges */ + .badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + gap: 4px; + white-space: nowrap; + } + + .badge-tool { + background: rgba(88, 166, 255, 0.15); + color: var(--accent-blue); + font-family: var(--font-mono); + font-size: 11px; + } + + .badge-userquery { + background: rgba(188, 140, 255, 0.15); + color: var(--accent-purple); + } + + .badge-task { + background: rgba(247, 120, 186, 0.15); + color: var(--accent-pink); + } + + .badge-agent { + background: rgba(57, 210, 192, 0.15); + color: var(--accent-cyan); + } + + .badge-error { + background: rgba(248, 81, 73, 0.15); + color: var(--accent-red); + } + + .badge-success { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); + } + + .badge-model { + background: rgba(210, 153, 34, 0.15); + color: var(--accent-orange); + } + + .badge-session { + background: rgba(88, 166, 255, 0.1); + color: var(--accent-blue); + border: 1px solid rgba(88, 166, 255, 0.2); + } + + .badge-status-active { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); + } + + .badge-status-completed { + background: rgba(139, 148, 158, 0.15); + color: var(--text-secondary); + } + + .badge-feature { + background: rgba(210, 153, 34, 0.1); + color: var(--accent-orange); + border: 1px solid rgba(210, 153, 34, 0.2); + } + + .badge-subagent { + background: rgba(57, 210, 192, 0.1); + color: var(--accent-cyan); + border: 1px solid rgba(57, 210, 192, 0.2); + } + + .badge-count { + background: var(--bg-tertiary); + color: var(--text-secondary); + min-width: 20px; + text-align: center; + } + + /* Stats row */ + .stats-badges { + display: flex; + gap: 6px; + align-items: center; + } + + /* Event dot indicator */ + .event-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 6px; + flex-shrink: 0; + } + + .event-dot.tool_call { background: var(--accent-blue); } + .event-dot.tool_result { background: var(--accent-green); } + .event-dot.error { background: var(--accent-red); } + .event-dot.task_delegation { background: var(--accent-pink); } + .event-dot.delegation { background: var(--accent-cyan); } + .event-dot.start { background: var(--accent-green); } + .event-dot.end { background: var(--text-muted); } + + /* Summary text */ + .summary-text { + color: var(--text-secondary); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + max-width: 500px; + } + + .summary-text.prompt { + color: var(--text-primary); + font-weight: 500; + } + + /* Timestamp */ + .timestamp { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); + } + + /* Duration */ + .duration { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + } + + /* New event flash animation */ + @keyframes flash-new { + 0% { background: rgba(63, 185, 80, 0.2); } + 100% { background: transparent; } + } + + .activity-row.new-event { + animation: flash-new 2s ease-out; + } + + /* Live indicator */ + .live-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--accent-green); + } + + .live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-green); + animation: pulse 2s ease-in-out infinite; + } + + /* Empty state */ + .empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); + } + + .empty-state h2 { + font-size: 18px; + margin-bottom: 8px; + color: var(--text-primary); + } + + /* Flash messages */ + .flash-group { padding: 0 24px; } + .flash-info { + background: rgba(88, 166, 255, 0.1); + border: 1px solid rgba(88, 166, 255, 0.3); + color: var(--accent-blue); + padding: 8px 16px; + border-radius: var(--radius); + margin-top: 8px; + } + .flash-error { + background: rgba(248, 81, 73, 0.1); + border: 1px solid rgba(248, 81, 73, 0.3); + color: var(--accent-red); + padding: 8px 16px; + border-radius: var(--radius); + margin-top: 8px; + } + + /* Scrollbar */ + ::-webkit-scrollbar { width: 8px; } + ::-webkit-scrollbar-track { background: var(--bg-primary); } + ::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: var(--bg-hover); } + """ + end +end From 566ce9bf771bb0a56c153cd12c57e8bae793fbe5 Mon Sep 17 00:00:00 2001 From: Shakes-tzd Date: Sat, 14 Mar 2026 05:12:05 -0400 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20Phoenix=20LiveView=20dashboard=20?= =?UTF-8?q?=E2=80=94=20fix=20styling,=20nesting,=20and=20data=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update Phoenix 1.8 + LiveView 1.1 + exqlite API compatibility - Fix CSS loading (HEEx doesn't interpolate inside + - <%= @inner_content %> + {@inner_content} diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex index 267ac550..8542b8c7 100644 --- a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex @@ -73,7 +73,6 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do @impl true def handle_info({:new_event, event}, socket) do - # Mark the new event for flash animation new_ids = MapSet.put(socket.assigns.new_event_ids, event["event_id"]) socket = @@ -81,7 +80,6 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do |> assign(:new_event_ids, new_ids) |> load_feed() - # Clear the flash after 3 seconds Process.send_after(self(), {:clear_new, event["event_id"]}, 3_000) {:noreply, socket} @@ -109,18 +107,22 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do # --- Template Helpers --- - defp tool_badge_class(tool_name) do + defp tool_chip_class(tool_name) do case tool_name do - "UserQuery" -> "badge-userquery" - "Task" -> "badge-task" - "Agent" -> "badge-agent" - "Bash" -> "badge-tool" - "Read" -> "badge-tool" - "Write" -> "badge-tool" - "Edit" -> "badge-tool" - "Glob" -> "badge-tool" - "Grep" -> "badge-tool" - _ -> "badge-tool" + "Bash" -> "tool-chip tool-chip-bash" + "Read" -> "tool-chip tool-chip-read" + "Edit" -> "tool-chip tool-chip-edit" + "Write" -> "tool-chip tool-chip-write" + "Grep" -> "tool-chip tool-chip-grep" + "Glob" -> "tool-chip tool-chip-glob" + "Task" -> "tool-chip tool-chip-task" + "Agent" -> "tool-chip tool-chip-task" + "TodoWrite" -> "tool-chip tool-chip-edit" + "TodoRead" -> "tool-chip tool-chip-read" + "TaskCreate" -> "tool-chip tool-chip-task" + "TaskOutput" -> "tool-chip tool-chip-task" + "Stop" -> "tool-chip tool-chip-stop" + _ -> "tool-chip tool-chip-default" end end @@ -137,7 +139,6 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do defp format_timestamp(nil), do: "" defp format_timestamp(ts) when is_binary(ts) do - # Extract HH:MM:SS from ISO timestamp case Regex.run(~r/(\d{2}:\d{2}:\d{2})/, ts) do [_, time] -> time _ -> ts @@ -145,8 +146,7 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do end defp format_duration(nil), do: "" - defp format_duration(0), do: "" - defp format_duration(0.0), do: "" + defp format_duration(+0.0), do: "" defp format_duration(seconds) when is_number(seconds) do cond do @@ -156,11 +156,6 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do end end - defp format_tokens(nil), do: "" - defp format_tokens(0), do: "" - defp format_tokens(tokens) when tokens > 1000, do: "#{Float.round(tokens / 1000, 1)}k" - defp format_tokens(tokens), do: "#{tokens}" - defp truncate(nil, _), do: "" defp truncate(text, max_len) when is_binary(text) do @@ -176,6 +171,10 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do length(children) > 0 end + defp descendant_count(event) do + event["descendant_count"] || child_count(event) + end + defp child_count(event) do children = event["children"] || [] length(children) @@ -202,12 +201,27 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do end end - defp tree_connector(depth) do - case depth do - 0 -> "├─" - 1 -> "│ ├─" - 2 -> "│ │ ├─" - _ -> "│ │ │ ├─" + defp is_task_event?(event) do + event["event_type"] == "task_delegation" or + (event["tool_name"] == "Task" and event["subagent_type"] != nil) + end + + defp row_border_class(event) do + cond do + is_task_event?(event) -> "border-task" + event["event_type"] == "error" -> "border-error" + true -> "" + end + end + + defp summary_text(event) do + input = event["input_summary"] || "" + output = event["output_summary"] || "" + + cond do + input != "" -> input + output != "" -> output + true -> "" end end @@ -248,7 +262,7 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do
- ▶ + ▶ @@ -263,11 +277,6 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do <%= group.session["agent_assigned"] %> <% end %> - <%= if group.session["model"] do %> - - <%= group.session["model"] %> - - <% end %> <% end %>
@@ -283,100 +292,74 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do
- - - - - - - - - - - - - - <%= for turn <- group.turns do %> - - - - - - - - - - - - - <%= if is_expanded?(@expanded, turn.user_query["event_id"]) do %> - <%= for child <- turn.children do %> - <.event_row - event={child} - expanded={@expanded} - new_event_ids={@new_event_ids} - /> - <% end %> + + + + + + <%= if is_expanded?(@expanded, turn.user_query["event_id"]) do %> + <%= for child <- turn.children do %> + <.event_row + event={child} + expanded={@expanded} + new_event_ids={@new_event_ids} + /> <% end %> <% end %> - -
EventSummaryBadgesTimeDurationTokens
- <%= if length(turn.children) > 0 do %> - - <% end %> - - - UserQuery - + <%= for turn <- group.turns do %> + +
+
+ <%= if length(turn.children) > 0 do %> + + <% end %> +
+
+
<%= truncate(turn.user_query["input_summary"], 100) %> -
-
- - <%= turn.stats.tool_count %> tools +
+
+ + <%= turn.stats.tool_count %> tools + + <%= if turn.stats.error_count > 0 do %> + + <%= turn.stats.error_count %> errors + + <% end %> + <%= if turn.work_item do %> + + <%= truncate(turn.work_item["title"], 30) %> - <%= if turn.stats.error_count > 0 do %> - - <%= turn.stats.error_count %> errors - - <% end %> - <%= if turn.work_item do %> - - <%= truncate(turn.work_item["title"], 30) %> - - <% end %> - <%= for model <- turn.stats.models do %> - <%= model %> - <% end %> -
-
+ <% end %> + <%= for model <- turn.stats.models do %> + <%= model %> + <% end %> <%= format_timestamp(turn.user_query["timestamp"]) %> - <%= format_duration(turn.stats.total_duration) %> - - - <%= format_tokens(turn.stats.total_tokens) %> - -
+ <% end %> + <% end %> <% end %> @@ -386,12 +369,15 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do defp event_row(assigns) do ~H""" - - + ]} + style={"padding-left: #{((@event["depth"] || 0) + 1) * 1.25}rem"} + > +
<%= if has_children?(@event) do %> <% end %> - - -
- - <%= tree_connector(@event["depth"] || 0) %> - - - - <%= @event["tool_name"] %> - +
+
+
+ + + + <%= @event["tool_name"] %> + + + <%= truncate(summary_text(@event), 80) %>
- - - - <%= truncate(@event["input_summary"] || @event["output_summary"], 80) %> - - - -
+
<%= if @event["subagent_type"] do %> <%= @event["subagent_type"] %> @@ -438,27 +417,18 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do <% end %> <%= if has_children?(@event) do %> - <%= child_count(@event) %> + (<%= descendant_count(@event) %>) <% end %> + + <%= format_timestamp(@event["timestamp"]) %> + + + <%= format_duration(@event["execution_duration_seconds"]) %> +
- - - - <%= format_timestamp(@event["timestamp"]) %> - - - - - <%= format_duration(@event["execution_duration_seconds"]) %> - - - - - <%= format_tokens(@event["cost_tokens"]) %> - - - +
+
<%= if is_expanded?(@expanded, @event["event_id"]) do %> diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex index b84c270f..32bacb1c 100644 --- a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex @@ -108,85 +108,108 @@ defmodule HtmlgraphDashboardWeb.Styles do gap: 12px; } - /* Activity Table */ - .activity-table { + /* Activity List (replaces table for flexible nesting) */ + .activity-list { width: 100%; - border-collapse: collapse; } - .activity-table th { - background: var(--bg-tertiary); - padding: 6px 12px; - text-align: left; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-secondary); - border-bottom: 1px solid var(--border); - position: sticky; - top: 49px; - z-index: 10; - } - - /* Row styles */ + /* Row styles — flex layout for nesting */ .activity-row { + display: flex; + align-items: center; border-bottom: 1px solid var(--border); transition: background 0.15s; + padding: 0 12px; + min-height: 36px; } .activity-row:hover { background: var(--bg-hover); } - .activity-row td { - padding: 6px 12px; - vertical-align: middle; - white-space: nowrap; + .row-toggle { + width: 32px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .row-content { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 6px 0; + gap: 12px; + } + + .row-summary { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + } + + .row-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; } /* Parent row (UserQuery) */ .activity-row.parent-row { background: var(--bg-secondary); + border-left: 3px solid var(--accent-blue); } .activity-row.parent-row:hover { background: var(--bg-tertiary); } - .activity-row.parent-row td { - padding: 10px 12px; + .activity-row.parent-row .row-content { + padding: 8px 0; + } + + .activity-row.parent-row .summary-text { font-weight: 500; } - /* Child rows — depth indentation */ - .activity-row.child-row { display: none; } - .activity-row.child-row.expanded { display: table-row; } + /* Child rows — depth indentation + progressive darkening */ + .activity-row.child-row { + border-left: 3px solid rgba(148,163,184,0.3); + } - .depth-indicator { - display: inline-flex; - align-items: center; - gap: 4px; + .activity-row.child-row.depth-0 { + background: rgba(0,0,0,0.15); + border-left-color: rgba(148,163,184,0.3); } - .depth-guide { - display: inline-block; - width: 20px; - border-left: 2px solid var(--border); - height: 100%; - margin-right: 0; + .activity-row.child-row.depth-1 { + background: rgba(0,0,0,0.25); + border-left-color: rgba(148,163,184,0.2); + } + + .activity-row.child-row.depth-2 { + background: rgba(0,0,0,0.35); + border-left-color: rgba(100,116,139,0.15); } - .depth-0 .depth-indent { padding-left: 24px; } - .depth-1 .depth-indent { padding-left: 48px; } - .depth-2 .depth-indent { padding-left: 72px; } - .depth-3 .depth-indent { padding-left: 96px; } + .activity-row.child-row.depth-3 { + background: rgba(0,0,0,0.45); + border-left-color: rgba(100,116,139,0.1); + } - .tree-connector { - color: var(--text-muted); - margin-right: 6px; - font-family: var(--font-mono); - font-size: 12px; + /* Task/error border overrides */ + .activity-row.child-row.border-task { + border-left-color: var(--accent-pink); + } + + .activity-row.child-row.border-error { + border-left-color: var(--accent-red); } /* Toggle button */ @@ -211,6 +234,7 @@ defmodule HtmlgraphDashboardWeb.Styles do .toggle-btn .arrow { display: inline-block; transition: transform 0.2s; + font-size: 10px; } .toggle-btn .arrow.expanded { @@ -229,28 +253,6 @@ defmodule HtmlgraphDashboardWeb.Styles do white-space: nowrap; } - .badge-tool { - background: rgba(88, 166, 255, 0.15); - color: var(--accent-blue); - font-family: var(--font-mono); - font-size: 11px; - } - - .badge-userquery { - background: rgba(188, 140, 255, 0.15); - color: var(--accent-purple); - } - - .badge-task { - background: rgba(247, 120, 186, 0.15); - color: var(--accent-pink); - } - - .badge-agent { - background: rgba(57, 210, 192, 0.15); - color: var(--accent-cyan); - } - .badge-error { background: rgba(248, 81, 73, 0.15); color: var(--accent-red); @@ -264,6 +266,7 @@ defmodule HtmlgraphDashboardWeb.Styles do .badge-model { background: rgba(210, 153, 34, 0.15); color: var(--accent-orange); + font-size: 10px; } .badge-session { @@ -286,6 +289,7 @@ defmodule HtmlgraphDashboardWeb.Styles do background: rgba(210, 153, 34, 0.1); color: var(--accent-orange); border: 1px solid rgba(210, 153, 34, 0.2); + font-size: 10px; } .badge-subagent { @@ -294,6 +298,11 @@ defmodule HtmlgraphDashboardWeb.Styles do border: 1px solid rgba(57, 210, 192, 0.2); } + .badge-agent { + background: rgba(57, 210, 192, 0.15); + color: var(--accent-cyan); + } + .badge-count { background: var(--bg-tertiary); color: var(--text-secondary); @@ -301,6 +310,64 @@ defmodule HtmlgraphDashboardWeb.Styles do text-align: center; } + /* Tool chip colors */ + .tool-chip { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + font-family: var(--font-mono); + white-space: nowrap; + flex-shrink: 0; + } + + .tool-chip-bash { + background: rgba(34,197,94,0.2); + color: #4ade80; + } + + .tool-chip-read { + background: rgba(96,165,250,0.2); + color: #60a5fa; + } + + .tool-chip-edit { + background: rgba(250,204,21,0.2); + color: #fbbf24; + } + + .tool-chip-write { + background: rgba(34,211,238,0.2); + color: #22d3ee; + } + + .tool-chip-grep { + background: rgba(251,146,60,0.2); + color: #fb923c; + } + + .tool-chip-glob { + background: rgba(168,85,247,0.2); + color: #a855f7; + } + + .tool-chip-task { + background: rgba(236,72,153,0.2); + color: #ec4899; + } + + .tool-chip-stop { + background: rgba(139,148,158,0.2); + color: #8b949e; + } + + .tool-chip-default { + background: rgba(88, 166, 255, 0.15); + color: var(--accent-blue); + } + /* Stats row */ .stats-badges { display: flex; @@ -314,7 +381,6 @@ defmodule HtmlgraphDashboardWeb.Styles do height: 8px; border-radius: 50%; display: inline-block; - margin-right: 6px; flex-shrink: 0; } @@ -332,7 +398,8 @@ defmodule HtmlgraphDashboardWeb.Styles do font-size: 13px; overflow: hidden; text-overflow: ellipsis; - max-width: 500px; + white-space: nowrap; + min-width: 0; } .summary-text.prompt { @@ -343,15 +410,17 @@ defmodule HtmlgraphDashboardWeb.Styles do /* Timestamp */ .timestamp { font-family: var(--font-mono); - font-size: 12px; + font-size: 11px; color: var(--text-muted); + white-space: nowrap; } /* Duration */ .duration { font-family: var(--font-mono); - font-size: 12px; + font-size: 11px; color: var(--text-secondary); + white-space: nowrap; } /* New event flash animation */ diff --git a/packages/phoenix-dashboard/mix.exs b/packages/phoenix-dashboard/mix.exs index f4a10036..2ccb143d 100644 --- a/packages/phoenix-dashboard/mix.exs +++ b/packages/phoenix-dashboard/mix.exs @@ -21,11 +21,11 @@ defmodule HtmlgraphDashboard.MixProject do defp deps do [ - {:phoenix, "~> 1.7.0"}, - {:phoenix_html, "~> 3.3"}, - {:phoenix_live_view, "~> 0.20.0"}, - {:phoenix_live_reload, "~> 1.4", only: :dev}, - {:phoenix_live_dashboard, "~> 0.8.0"}, + {:phoenix, "~> 1.8"}, + {:phoenix_html, "~> 4.2"}, + {:phoenix_live_view, "~> 1.1"}, + {:phoenix_live_reload, "~> 1.6", only: :dev}, + {:phoenix_live_dashboard, "~> 0.8"}, {:exqlite, "~> 0.13"}, {:jason, "~> 1.4"}, {:plug_cowboy, "~> 2.6"}, diff --git a/packages/phoenix-dashboard/mix.lock b/packages/phoenix-dashboard/mix.lock new file mode 100644 index 00000000..157282e6 --- /dev/null +++ b/packages/phoenix-dashboard/mix.lock @@ -0,0 +1,30 @@ +%{ + "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/src/python/htmlgraph/cli/work/graph.py b/src/python/htmlgraph/cli/work/graph.py new file mode 100644 index 00000000..10ca45d4 --- /dev/null +++ b/src/python/htmlgraph/cli/work/graph.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +"""HtmlGraph CLI - Graph neighborhood command. + +Shows the edge neighborhood of a node: direct incoming and outgoing edges +with their relationship types. +""" + +import argparse +import sqlite3 +from typing import TYPE_CHECKING + +from rich.console import Console +from rich.table import Table + +from htmlgraph.cli.base import BaseCommand, CommandResult +from htmlgraph.cli.constants import DEFAULT_GRAPH_DIR + +if TYPE_CHECKING: + from argparse import _SubParsersAction + +console = Console() + + +def register_graph_commands(subparsers: _SubParsersAction) -> None: + """Register graph neighborhood commands.""" + graph_parser = subparsers.add_parser( + "graph", + help="Show graph neighborhood (direct edges) for a node", + ) + graph_parser.add_argument("id", help="Node ID to inspect") + graph_parser.add_argument( + "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory" + ) + graph_parser.add_argument( + "--format", choices=["json", "text"], default="text", help="Output format" + ) + graph_parser.set_defaults(func=GraphNeighborhoodCommand.from_args) + + +class GraphNeighborhoodCommand(BaseCommand): + """Show the graph neighborhood (direct edges) of a node.""" + + def __init__(self, *, node_id: str) -> None: + super().__init__() + self.node_id = node_id + + @classmethod + def from_args(cls, args: argparse.Namespace) -> GraphNeighborhoodCommand: + return cls(node_id=args.id) + + def execute(self) -> CommandResult: + """Show node neighborhood with typed edges.""" + sdk = self.get_sdk() + db_path = sdk._db.db_path + + # Look up node title from in-memory graph (try all collections) + node_title = self._find_node_title(sdk, self.node_id) + + # Query edges from SQLite graph_edges table + outgoing: list[dict] = [] + incoming: list[dict] = [] + + conn = sqlite3.connect(str(db_path), timeout=2.0, check_same_thread=False) + conn.row_factory = sqlite3.Row + try: + cursor = conn.execute( + """ + SELECT edge_id, from_node_id, from_node_type, + to_node_id, to_node_type, relationship_type, weight, created_at + FROM graph_edges + WHERE from_node_id = ? + ORDER BY relationship_type, created_at DESC + """, + (self.node_id,), + ) + outgoing = [dict(row) for row in cursor.fetchall()] + + cursor = conn.execute( + """ + SELECT edge_id, from_node_id, from_node_type, + to_node_id, to_node_type, relationship_type, weight, created_at + FROM graph_edges + WHERE to_node_id = ? + ORDER BY relationship_type, created_at DESC + """, + (self.node_id,), + ) + incoming = [dict(row) for row in cursor.fetchall()] + finally: + conn.close() + + # Also collect edges from in-memory graph (HTML edges) + html_outgoing, html_incoming = self._collect_html_edges(sdk, self.node_id) + + # Build output table + header = f"[bold cyan]{self.node_id}[/bold cyan]" + if node_title: + header += f" [yellow]({node_title})[/yellow]" + + table = Table(show_header=True, header_style="bold magenta", show_lines=False) + table.add_column("Direction", style="dim", width=4) + table.add_column("Relationship", style="green") + table.add_column("Node", style="cyan") + + # Outgoing edges from SQLite + for edge in outgoing: + target_id = edge["to_node_id"] + target_title = self._find_node_title(sdk, target_id) + target_label = ( + f"{target_id} ({target_title})" if target_title else target_id + ) + table.add_row("→", edge["relationship_type"], target_label) + + # Outgoing edges from HTML graph (de-duplicated against SQLite) + sqlite_out_pairs = {(e["to_node_id"], e["relationship_type"]) for e in outgoing} + for target_id, rel_type in html_outgoing: + if (target_id, rel_type) not in sqlite_out_pairs: + target_title = self._find_node_title(sdk, target_id) + target_label = ( + f"{target_id} ({target_title})" if target_title else target_id + ) + table.add_row("→", rel_type, target_label) + + # Incoming edges from SQLite + for edge in incoming: + source_id = edge["from_node_id"] + source_title = self._find_node_title(sdk, source_id) + source_label = ( + f"{source_id} ({source_title})" if source_title else source_id + ) + table.add_row("←", edge["relationship_type"], source_label) + + # Incoming edges from HTML graph (de-duplicated) + sqlite_in_pairs = { + (e["from_node_id"], e["relationship_type"]) for e in incoming + } + for source_id, rel_type in html_incoming: + if (source_id, rel_type) not in sqlite_in_pairs: + source_title = self._find_node_title(sdk, source_id) + source_label = ( + f"{source_id} ({source_title})" if source_title else source_id + ) + table.add_row("←", rel_type, source_label) + + total_edges = ( + len(outgoing) + len(html_outgoing) + len(incoming) + len(html_incoming) + ) + + if total_edges == 0: + from htmlgraph.cli.base import TextOutputBuilder + + output = TextOutputBuilder() + output.add_field("Node", header) + output.add_warning("No edges found for this node.") + return CommandResult( + text=output.build(), + json_data={ + "node_id": self.node_id, + "title": node_title, + "outgoing": outgoing, + "incoming": incoming, + }, + ) + + console.print(f"\nNode: {header}") + console.print("[dim]Edges:[/dim]") + + return CommandResult( + data=table, + json_data={ + "node_id": self.node_id, + "title": node_title, + "outgoing": outgoing + + [ + { + "from_node_id": self.node_id, + "to_node_id": t, + "relationship_type": r, + } + for t, r in html_outgoing + ], + "incoming": incoming + + [ + { + "from_node_id": s, + "to_node_id": self.node_id, + "relationship_type": r, + } + for s, r in html_incoming + ], + }, + ) + + def _find_node_title(self, sdk: object, node_id: str) -> str | None: + """Look up a node title across all collections.""" + from htmlgraph.sdk import SDK + + if not isinstance(sdk, SDK): + return None + + for collection_name in ("features", "bugs", "spikes"): + coll = getattr(sdk, collection_name, None) + if coll is None: + continue + try: + node = coll.get(node_id) + if node is not None: + return str(node.title) + except Exception: + continue + return None + + def _collect_html_edges( + self, sdk: object, node_id: str + ) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: + """ + Collect edges from the in-memory HTML graph for the given node. + + Returns: + Tuple of (outgoing, incoming) where each is a list of (other_id, rel_type) + """ + from htmlgraph.sdk import SDK + + if not isinstance(sdk, SDK): + return [], [] + + outgoing: list[tuple[str, str]] = [] + incoming: list[tuple[str, str]] = [] + + # Check outgoing edges from the node's own HTML data + for collection_name in ("features", "bugs", "spikes"): + coll = getattr(sdk, collection_name, None) + if coll is None: + continue + try: + node = coll.get(node_id) + if node is not None: + for rel_type, edges in node.edges.items(): + for edge in edges: + outgoing.append((edge.target_id, rel_type)) + except Exception: + continue + + # Check incoming edges: scan all nodes for edges pointing TO node_id + for collection_name in ("features", "bugs", "spikes"): + coll = getattr(sdk, collection_name, None) + if coll is None: + continue + try: + graph = coll._ensure_graph() + in_edges = graph.get_incoming_edges(node_id) + for edge_ref in in_edges: + incoming.append((edge_ref.source_id, edge_ref.relationship)) + except Exception: + continue + + return outgoing, incoming From 3f1e23288f9efb52ca19ecad5805942a8578914d Mon Sep 17 00:00:00 2001 From: Shakes-tzd Date: Sat, 14 Mar 2026 05:45:25 -0400 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20Phoenix=20dashboard=20=E2=80=94=20?= =?UTF-8?q?live=20refresh,=20flicker,=20session=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add phx-key attributes on session groups, turns, and events for stable DOM identity (eliminates flicker) - Debounce event reload to 500ms (prevents rapid re-renders) - Remove new_event_ids highlight mechanism (caused double re-render) - Session cards now show last prompt text instead of raw UUIDs - Derive session status from data: active/idle/completed (idle = no events in 30 min) - Remove NULL AS model — use real model column from sessions table - Suppress "system" agent label when not meaningful - Add badge-status-idle CSS (amber) and session-subtitle styling Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/phoenix-dashboard/assets/css/app.css | 20 ++- .../lib/htmlgraph_dashboard/activity.ex | 53 ++++++- .../live/activity_feed_live.ex | 129 +++++++++++++----- 3 files changed, 165 insertions(+), 37 deletions(-) diff --git a/packages/phoenix-dashboard/assets/css/app.css b/packages/phoenix-dashboard/assets/css/app.css index 511c3b0b..f000cdde 100644 --- a/packages/phoenix-dashboard/assets/css/app.css +++ b/packages/phoenix-dashboard/assets/css/app.css @@ -105,6 +105,17 @@ body { gap: 12px; } +.session-subtitle { + background: var(--bg-secondary); + padding: 4px 16px 8px 48px; + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + /* Activity List (replaces table for flexible nesting) */ .activity-list { width: 100%; @@ -278,8 +289,13 @@ body { } .badge-status-completed { - background: rgba(139, 148, 158, 0.15); - color: var(--text-secondary); + background: rgba(148, 163, 184, 0.2); + color: #94a3b8; +} + +.badge-status-idle { + background: rgba(251, 191, 36, 0.2); + color: #fbbf24; } .badge-feature { diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex index abbe786b..7c3d65b3 100644 --- a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex @@ -457,13 +457,62 @@ defmodule HtmlgraphDashboard.Activity do sql = """ SELECT session_id, agent_assigned, status, created_at, completed_at, total_events, total_tokens_used, is_subagent, last_user_query, - NULL AS model + model FROM sessions WHERE session_id = ? """ case Repo.query_maps(sql, [session_id]) do - {:ok, [session]} -> session + {:ok, [session]} -> derive_session_status(session) + _ -> nil + end + end + + defp derive_session_status(session) do + cond do + # If completed_at is set, it's completed + session["completed_at"] != nil -> + Map.put(session, "status", "completed") + + # If status is already explicitly set to something other than active, keep it + session["status"] not in [nil, "active"] -> + session + + # Check if the session's last event is older than 30 minutes + true -> + case last_event_timestamp(session["session_id"]) do + nil -> + session + + ts_string -> + case NaiveDateTime.from_iso8601(ts_string) do + {:ok, last_event_ts} -> + cutoff = NaiveDateTime.add(NaiveDateTime.utc_now(), -30, :minute) + + if NaiveDateTime.compare(last_event_ts, cutoff) == :lt do + Map.put(session, "status", "idle") + else + session + end + + _ -> + session + end + end + end + end + + defp last_event_timestamp(nil), do: nil + + defp last_event_timestamp(session_id) do + sql = """ + SELECT MAX(timestamp) AS last_ts + FROM agent_events + WHERE session_id = ? + """ + + case Repo.query_maps(sql, [session_id]) do + {:ok, [%{"last_ts" => ts}]} -> ts _ -> nil end end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex index 8542b8c7..0c2f36a8 100644 --- a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex @@ -25,7 +25,7 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do socket |> assign(:session_filter, session_id) |> assign(:expanded, MapSet.new()) - |> assign(:new_event_ids, MapSet.new()) + |> assign(:reload_timer, nil) |> load_feed() {:ok, socket} @@ -72,24 +72,26 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do end @impl true - def handle_info({:new_event, event}, socket) do - new_ids = MapSet.put(socket.assigns.new_event_ids, event["event_id"]) + def handle_info({:new_event, _event}, socket) do + # Debounce: schedule a single reload 500ms from now + # Cancel any existing pending reload to avoid redundant work + if socket.assigns[:reload_timer] do + Process.cancel_timer(socket.assigns.reload_timer) + end + + timer = Process.send_after(self(), :do_reload, 500) + {:noreply, assign(socket, :reload_timer, timer)} + end + def handle_info(:do_reload, socket) do socket = socket - |> assign(:new_event_ids, new_ids) + |> assign(:reload_timer, nil) |> load_feed() - Process.send_after(self(), {:clear_new, event["event_id"]}, 3_000) - {:noreply, socket} end - def handle_info({:clear_new, event_id}, socket) do - new_ids = MapSet.delete(socket.assigns.new_event_ids, event_id) - {:noreply, assign(socket, :new_event_ids, new_ids)} - end - defp load_feed(socket) do opts = case socket.assigns[:session_filter] do @@ -184,10 +186,6 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do MapSet.member?(expanded, event_id) end - defp is_new?(new_event_ids, event_id) do - MapSet.member?(new_event_ids, event_id) - end - defp session_expanded?(expanded, session_id) do MapSet.member?(expanded, "session:#{session_id}") end @@ -225,6 +223,54 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do end end + defp session_title(group) do + # Prefer last_user_query from the session record + lq = group.session && group.session["last_user_query"] + + if is_binary(lq) and String.trim(lq) != "" do + truncate(String.trim(lq), 80) + else + # Fall back to first turn's prompt text + case group.turns do + [first | _] -> + text = first.user_query["input_summary"] || "" + + if String.trim(text) != "" do + truncate(text, 80) + else + truncate(group.session_id, 12) + end + + [] -> + truncate(group.session_id, 12) + end + end + end + + defp agent_label(nil), do: nil + defp agent_label("system"), do: nil + defp agent_label(""), do: nil + defp agent_label(name), do: name + + defp format_relative_time(nil), do: "" + + defp format_relative_time(ts) when is_binary(ts) do + case NaiveDateTime.from_iso8601(ts) do + {:ok, ndt} -> + diff = NaiveDateTime.diff(NaiveDateTime.utc_now(), ndt, :second) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)}m ago" + diff < 86400 -> "#{div(diff, 3600)}h ago" + true -> "#{div(diff, 86400)}d ago" + end + + _ -> + format_timestamp(ts) + end + end + @impl true def render(assigns) do ~H""" @@ -252,7 +298,7 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do
<% else %> <%= for group <- @feed do %> -
+
- - <%= truncate(group.session_id, 12) %> + + <%= session_title(group) %> <%= if group.session do %> <%= group.session["status"] || "active" %> - <%= if group.session["agent_assigned"] do %> + <%= if agent_label(group.session["agent_assigned"]) do %> - <%= group.session["agent_assigned"] %> + <%= agent_label(group.session["agent_assigned"]) %> <% end %> <% end %> @@ -291,6 +337,24 @@ defmodule HtmlgraphDashboardWeb.ActivityFeedLive do
+ +
+ <%= if group.session do %> + + Started: <%= format_relative_time(group.session["created_at"]) %> + + + <%= truncate(group.session_id, 16) %> + + <%= if group.session["model"] do %> + <%= group.session["model"] %> + <% end %> + <% end %> +
+
<%= for turn <- group.turns do %> -
+
<%= if length(turn.children) > 0 do %>