Rewrite Mission Control to use Engram sessions as task history
This commit is contained in:
parent
495ccb4b05
commit
555665fdea
@ -1467,6 +1467,199 @@
|
||||
.engram-session-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Mission Control page */
|
||||
.mc-page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.mc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mc-header h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.mc-subtitle {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.mc-back-link {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.mc-stats-bar {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mc-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mc-stat-num {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.mc-stat-num.mc-active {
|
||||
color: var(--green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mc-stat-lbl {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mc-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mc-filter-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.mc-filter-btn:hover {
|
||||
border-color: var(--blue);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mc-filter-btn.active {
|
||||
background: var(--blue-bg);
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.mc-filter-count {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.mc-loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mc-session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mc-session-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mc-session-card:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--blue);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mc-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mc-type-badge {
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--mono);
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.mc-type-cowork { color: var(--blue); background: var(--blue-bg); }
|
||||
.mc-type-code { color: var(--green); background: var(--green-bg); }
|
||||
.mc-type-desktop { color: var(--yellow); background: var(--yellow-bg); }
|
||||
.mc-type-api { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); }
|
||||
.mc-type-other, .mc-type-test { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); }
|
||||
|
||||
.mc-status-pill {
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--mono);
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mc-st-completed { color: var(--green); background: var(--green-bg); }
|
||||
.mc-st-active { color: var(--blue); background: var(--blue-bg); }
|
||||
.mc-st-unknown { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); }
|
||||
|
||||
.mc-card-date {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.mc-card-summary {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mc-card-outcome {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.4rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid var(--border);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mc-empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
<script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script>
|
||||
</head>
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
defmodule CortexStatusWeb.TaskLive do
|
||||
@moduledoc """
|
||||
Mission Control LiveView for Symbiont compound task orchestration.
|
||||
Provides real-time visualization of task decomposition, subtask routing,
|
||||
execution progress, and costs.
|
||||
Mission Control — session history browser powered by Engram.
|
||||
Shows all recorded sessions with clickable detail views including
|
||||
activity log timelines.
|
||||
"""
|
||||
use CortexStatusWeb, :live_view
|
||||
|
||||
@poll_interval 1_000
|
||||
@auth_token "cortex-tasks-2026"
|
||||
@http_timeout 5_000
|
||||
|
||||
defp symbiont_url do
|
||||
config = Application.get_env(:cortex_status, :services, [])
|
||||
@ -16,544 +15,352 @@ defmodule CortexStatusWeb.TaskLive do
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status")
|
||||
send(self(), :load_sessions)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: "Mission Control",
|
||||
authenticated: false,
|
||||
password_input: "",
|
||||
auth_error: nil,
|
||||
prompt: "",
|
||||
active_task: nil,
|
||||
task_history: [],
|
||||
submitting: false,
|
||||
submit_error: nil,
|
||||
poll_timer: nil
|
||||
sessions: [],
|
||||
stats: %{},
|
||||
selected_session: nil,
|
||||
session_logs: [],
|
||||
modal_loading: false,
|
||||
filter: "all",
|
||||
loading: true
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("authenticate", %{"password" => password}, socket) do
|
||||
if password == @auth_token do
|
||||
# Load recent tasks on auth
|
||||
task_history = fetch_recent_tasks()
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
authenticated: true,
|
||||
password_input: "",
|
||||
auth_error: nil,
|
||||
task_history: task_history
|
||||
)}
|
||||
else
|
||||
{:noreply, assign(socket, auth_error: "Invalid password", password_input: "")}
|
||||
end
|
||||
def handle_info(:load_sessions, socket) do
|
||||
{stats, sessions} = fetch_engram_data()
|
||||
{:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
|
||||
end
|
||||
|
||||
def handle_event("update_prompt", %{"prompt" => prompt}, socket) do
|
||||
{:noreply, assign(socket, prompt: prompt)}
|
||||
def handle_info({:status_update, new_status}, socket) do
|
||||
# Piggyback on the monitor's engram data to stay fresh
|
||||
engram = new_status.engram
|
||||
stats = engram.stats
|
||||
sessions = engram.sessions
|
||||
|
||||
{:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
|
||||
end
|
||||
|
||||
def handle_event("submit_task", %{"prompt" => prompt}, socket) do
|
||||
if String.trim(prompt) == "" do
|
||||
{:noreply, socket}
|
||||
else
|
||||
socket = assign(socket, submitting: true, submit_error: nil)
|
||||
case submit_compound_task(prompt) do
|
||||
{:ok, task_data} ->
|
||||
# The POST response only has {id, status, subtask_count}.
|
||||
# Build a placeholder with empty subtasks until first poll fills it in.
|
||||
placeholder = %{
|
||||
"id" => task_data["id"],
|
||||
"prompt" => String.trim(prompt),
|
||||
"status" => task_data["status"] || "planned",
|
||||
"reasoning" => nil,
|
||||
"subtasks" => [],
|
||||
"created_at" => DateTime.to_iso8601(DateTime.utc_now()),
|
||||
"completed_at" => nil,
|
||||
"total_cost" => 0.0
|
||||
}
|
||||
def handle_info({:fetch_logs, session_id}, socket) do
|
||||
logs =
|
||||
case Req.get("#{symbiont_url()}/engram/sessions/#{session_id}/logs?limit=200",
|
||||
receive_timeout: @http_timeout
|
||||
) do
|
||||
{:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) ->
|
||||
Enum.reverse(logs)
|
||||
|
||||
timer = Process.send_after(self(), :poll_task, 200)
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
submitting: false,
|
||||
active_task: placeholder,
|
||||
prompt: "",
|
||||
submit_error: nil,
|
||||
poll_timer: timer
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
submitting: false,
|
||||
active_task: nil,
|
||||
submit_error: "Failed to submit task: #{inspect(reason)}"
|
||||
)}
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("logout", _, socket) do
|
||||
if socket.assigns.poll_timer do
|
||||
Process.cancel_timer(socket.assigns.poll_timer)
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
authenticated: false,
|
||||
password_input: "",
|
||||
active_task: nil,
|
||||
prompt: "",
|
||||
task_history: [],
|
||||
poll_timer: nil
|
||||
)}
|
||||
{:noreply, assign(socket, session_logs: logs, modal_loading: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:poll_task, socket) do
|
||||
case socket.assigns.active_task do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
def handle_event("select_session", %{"id" => session_id}, socket) do
|
||||
session = Enum.find(socket.assigns.sessions, fn s -> gv(s, "id") == session_id end)
|
||||
|
||||
task ->
|
||||
case fetch_task_progress(task["id"]) do
|
||||
{:ok, updated_task} when is_map(updated_task) ->
|
||||
status = updated_task["status"] || "unknown"
|
||||
terminal = status in ["completed", "failed", "partial"]
|
||||
|
||||
timer =
|
||||
if terminal do
|
||||
# Refresh history when task finishes
|
||||
nil
|
||||
else
|
||||
Process.send_after(self(), :poll_task, @poll_interval)
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
assign(socket,
|
||||
active_task: updated_task,
|
||||
poll_timer: timer
|
||||
)}
|
||||
|
||||
_ ->
|
||||
# API error or malformed response — keep polling but don't crash
|
||||
timer = Process.send_after(self(), :poll_task, @poll_interval)
|
||||
{:noreply, assign(socket, poll_timer: timer)}
|
||||
end
|
||||
if session do
|
||||
send(self(), {:fetch_logs, session_id})
|
||||
{:noreply, assign(socket, selected_session: session, session_logs: [], modal_loading: true)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("close_modal", _params, socket) do
|
||||
{:noreply, assign(socket, selected_session: nil, session_logs: [], modal_loading: false)}
|
||||
end
|
||||
|
||||
def handle_event("filter", %{"type" => type}, socket) do
|
||||
{:noreply, assign(socket, filter: type)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
# Apply filter
|
||||
filtered =
|
||||
assigns.sessions
|
||||
|> Enum.reject(fn s -> gv(s, "session_type") == "test" end)
|
||||
|> then(fn sessions ->
|
||||
case assigns.filter do
|
||||
"all" -> sessions
|
||||
type -> Enum.filter(sessions, fn s -> gv(s, "session_type") == type end)
|
||||
end
|
||||
end)
|
||||
|> Enum.sort_by(fn s -> gv(s, "started_at", "") end, :desc)
|
||||
|
||||
# Compute type counts for filter badges
|
||||
all_real =
|
||||
assigns.sessions
|
||||
|> Enum.reject(fn s -> gv(s, "session_type") == "test" end)
|
||||
|
||||
type_counts =
|
||||
all_real
|
||||
|> Enum.frequencies_by(fn s -> gv(s, "session_type", "other") end)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:filtered, filtered)
|
||||
|> assign(:type_counts, type_counts)
|
||||
|> assign(:total_real, length(all_real))
|
||||
|
||||
~H"""
|
||||
<div class="mission-control">
|
||||
<%= if !@authenticated do %>
|
||||
<.auth_gate auth_error={@auth_error} />
|
||||
<div class="mc-page">
|
||||
<header class="mc-header">
|
||||
<div class="mc-header-left">
|
||||
<h1>Mission Control</h1>
|
||||
<span class="mc-subtitle">Engram Session History</span>
|
||||
</div>
|
||||
<a href="/" class="mc-back-link">← Status</a>
|
||||
</header>
|
||||
|
||||
<div class="mc-stats-bar">
|
||||
<div class="mc-stat">
|
||||
<span class="mc-stat-num"><%= gv(@stats, "total_sessions", 0) %></span>
|
||||
<span class="mc-stat-lbl">Sessions</span>
|
||||
</div>
|
||||
<div class="mc-stat">
|
||||
<span class="mc-stat-num"><%= gv(@stats, "total_logs", 0) %></span>
|
||||
<span class="mc-stat-lbl">Log entries</span>
|
||||
</div>
|
||||
<div class="mc-stat">
|
||||
<span class={"mc-stat-num #{if gv(@stats, "active_sessions", 0) > 0, do: "mc-active", else: ""}"}>
|
||||
<%= gv(@stats, "active_sessions", 0) %>
|
||||
</span>
|
||||
<span class="mc-stat-lbl">Active now</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mc-filters">
|
||||
<button
|
||||
class={"mc-filter-btn #{if @filter == "all", do: "active", else: ""}"}
|
||||
phx-click="filter"
|
||||
phx-value-type="all"
|
||||
>All <span class="mc-filter-count"><%= @total_real %></span></button>
|
||||
<%= for {type, count} <- Enum.sort(@type_counts) do %>
|
||||
<button
|
||||
class={"mc-filter-btn #{if @filter == type, do: "active", else: ""}"}
|
||||
phx-click="filter"
|
||||
phx-value-type={type}
|
||||
><%= type %> <span class="mc-filter-count"><%= count %></span></button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @loading do %>
|
||||
<div class="mc-loading">Loading sessions...</div>
|
||||
<% else %>
|
||||
<.task_interface
|
||||
prompt={@prompt}
|
||||
submitting={@submitting}
|
||||
active_task={@active_task}
|
||||
task_history={@task_history}
|
||||
submit_error={@submit_error}
|
||||
<div class="mc-session-list">
|
||||
<%= for session <- @filtered do %>
|
||||
<div
|
||||
class={"mc-session-card #{gv(session, "status", "unknown")}"}
|
||||
phx-click="select_session"
|
||||
phx-value-id={gv(session, "id", "")}
|
||||
>
|
||||
<div class="mc-card-top">
|
||||
<span class={"mc-type-badge mc-type-#{gv(session, "session_type", "other")}"}><%= gv(session, "session_type", "?") %></span>
|
||||
<span class={"mc-status-pill mc-st-#{gv(session, "status", "unknown")}"}><%= gv(session, "status", "?") %></span>
|
||||
<span class="mc-card-date"><%= fmt_date(gv(session, "started_at", "")) %></span>
|
||||
</div>
|
||||
<div class="mc-card-summary"><%= gv(session, "summary", "\u2014") %></div>
|
||||
<%= if gv(session, "completion_summary", nil) do %>
|
||||
<div class="mc-card-outcome"><%= gv(session, "completion_summary", "") %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @filtered == [] do %>
|
||||
<div class="mc-empty">No sessions match this filter.</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_session do %>
|
||||
<.session_modal
|
||||
session={@selected_session}
|
||||
logs={@session_logs}
|
||||
loading={@modal_loading}
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :auth_error, :string, default: nil
|
||||
# ── Session Detail Modal ──
|
||||
|
||||
attr :session, :map, required: true
|
||||
attr :logs, :list, required: true
|
||||
attr :loading, :boolean, default: false
|
||||
|
||||
defp session_modal(assigns) do
|
||||
session = assigns.session
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:sid, gv(session, "id", ""))
|
||||
|> assign(:stype, gv(session, "session_type", "?"))
|
||||
|> assign(:status_val, gv(session, "status", "unknown"))
|
||||
|> assign(:summary, gv(session, "summary", "\u2014"))
|
||||
|> assign(:started, gv(session, "started_at", ""))
|
||||
|> assign(:completed, gv(session, "completed_at", nil))
|
||||
|> assign(:completion_summary, gv(session, "completion_summary", nil))
|
||||
|
||||
defp auth_gate(assigns) do
|
||||
~H"""
|
||||
<div class="auth-gate">
|
||||
<div class="auth-container">
|
||||
<h1>Mission Control</h1>
|
||||
<p class="auth-subtitle">Symbiont Task Orchestration</p>
|
||||
|
||||
<%= if @auth_error do %>
|
||||
<div class="auth-error">
|
||||
<%= @auth_error %>
|
||||
<div class="modal-backdrop" phx-click="close_modal">
|
||||
<div class="modal-content" phx-click-away="close_modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-row">
|
||||
<span class={"mc-type-badge mc-type-#{@stype}"}><%= @stype %></span>
|
||||
<span class={"mc-status-pill mc-st-#{@status_val}"}><%= @status_val %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<form phx-submit="authenticate" id="auth-form" class="auth-form">
|
||||
<input
|
||||
type="password"
|
||||
id="password-input"
|
||||
name="password"
|
||||
placeholder="Enter authorization token"
|
||||
class="auth-input"
|
||||
autofocus
|
||||
/>
|
||||
<button type="submit" class="auth-button">Access Mission Control</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
<a href="/">← Back to Status</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :prompt, :string, default: ""
|
||||
attr :submitting, :boolean, default: false
|
||||
attr :active_task, :any, default: nil
|
||||
attr :task_history, :list, default: []
|
||||
attr :submit_error, :string, default: nil
|
||||
|
||||
defp task_interface(assigns) do
|
||||
~H"""
|
||||
<div class="task-interface">
|
||||
<header class="task-header">
|
||||
<h1>Mission Control</h1>
|
||||
<button phx-click="logout" class="logout-button">Logout</button>
|
||||
</header>
|
||||
|
||||
<div class="task-main">
|
||||
<div class="task-input-section">
|
||||
<form phx-submit="submit_task" phx-change="update_prompt" class="task-form">
|
||||
<label for="prompt-input">Compound Task Prompt</label>
|
||||
<textarea
|
||||
id="prompt-input"
|
||||
name="prompt"
|
||||
class="task-textarea"
|
||||
placeholder="Describe the task to decompose..."
|
||||
><%= @prompt %></textarea>
|
||||
<%= if @submit_error do %>
|
||||
<div class="submit-error"><%= @submit_error %></div>
|
||||
<% end %>
|
||||
<button type="submit" class="execute-button" disabled={@submitting}>
|
||||
<%= if @submitting, do: "Submitting...", else: "Execute Task" %>
|
||||
</button>
|
||||
</form>
|
||||
<button class="modal-close" phx-click="close_modal">×</button>
|
||||
</div>
|
||||
|
||||
<%= if @active_task do %>
|
||||
<.task_visualization task={@active_task} />
|
||||
<% else %>
|
||||
<.task_history history={@task_history} />
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
<div class="modal-body">
|
||||
<h3 class="modal-summary"><%= @summary %></h3>
|
||||
|
||||
defp task_visualization(assigns) do
|
||||
~H"""
|
||||
<div class="task-visualization">
|
||||
<div class="task-metadata">
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Task ID</span>
|
||||
<span class="metadata-value"><%= @task["id"] %></span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Status</span>
|
||||
<span class={"metadata-status status-#{@task["status"]}"}>
|
||||
<%= String.upcase(@task["status"]) %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Cost</span>
|
||||
<span class="metadata-value">$<%= format_cost(@task["total_cost"]) %></span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Subtasks</span>
|
||||
<span class="metadata-value"><%= length(@task["subtasks"] || []) %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-card">
|
||||
<div class="prompt-label">Original Prompt</div>
|
||||
<div class="prompt-text"><%= @task["prompt"] %></div>
|
||||
</div>
|
||||
|
||||
<%= if @task["reasoning"] do %>
|
||||
<div class="reasoning-card">
|
||||
<div class="reasoning-label">Decomposition Reasoning</div>
|
||||
<div class="reasoning-text"><%= @task["reasoning"] %></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="task-flow">
|
||||
<div class="flow-header">Task Flow Visualization</div>
|
||||
<.task_flow_graph subtasks={@task["subtasks"] || []} />
|
||||
</div>
|
||||
|
||||
<div class="task-details">
|
||||
<div class="details-header">Subtask Details</div>
|
||||
<div class="subtasks-list">
|
||||
<%= for subtask <- @task["subtasks"] || [] do %>
|
||||
<.subtask_detail subtask={subtask} />
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp task_flow_graph(assigns) do
|
||||
# Group subtasks by wave (dependency level)
|
||||
subtasks = assigns.subtasks || []
|
||||
waves = compute_waves(subtasks)
|
||||
|
||||
~H"""
|
||||
<div class="flow-container">
|
||||
<%= for {wave_tasks, wave_num} <- Enum.with_index(waves) do %>
|
||||
<div class="wave-row">
|
||||
<div class="wave-label">Wave <%= wave_num %></div>
|
||||
<div class="wave-tasks">
|
||||
<%= for subtask <- wave_tasks do %>
|
||||
<.subtask_card subtask={subtask} />
|
||||
<div class="modal-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Session ID</span>
|
||||
<span class="meta-value mono"><%= @sid %></span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Started</span>
|
||||
<span class="meta-value"><%= fmt_full(@started) %></span>
|
||||
</div>
|
||||
<%= if @completed do %>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Completed</span>
|
||||
<span class="meta-value"><%= fmt_full(@completed) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subtask_card(assigns) do
|
||||
~H"""
|
||||
<div class={"subtask-card status-#{@subtask["status"]}"}>
|
||||
<div class="card-header-row">
|
||||
<div class="card-status-icon">
|
||||
<%= case @subtask["status"] do %>
|
||||
<% "completed" -> %>
|
||||
✓
|
||||
<% "failed" -> %>
|
||||
✗
|
||||
<% "executing" -> %>
|
||||
◆
|
||||
<% _ -> %>
|
||||
◇
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="card-title">
|
||||
<%= String.slice(@subtask["description"], 0..40) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<%= if @subtask["model"] do %>
|
||||
<span class={"model-badge tier-#{@subtask["tier_assigned"]}"}>
|
||||
<%= @subtask["model"] %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @subtask["status"] == "executing" do %>
|
||||
<div class="executing-indicator">
|
||||
<span class="pulse-dot"></span>
|
||||
Executing...
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @subtask["result"] do %>
|
||||
<div class="card-result">
|
||||
<%= String.slice(@subtask["result"], 0..60) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="card-cost">
|
||||
$<%= format_cost(@subtask["cost"]) %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subtask_detail(assigns) do
|
||||
~H"""
|
||||
<div class={"subtask-item status-#{@subtask["status"]}"}>
|
||||
<div class="item-header">
|
||||
<span class="item-index">
|
||||
Subtask <%= @subtask["index"] %>
|
||||
</span>
|
||||
<span class={"item-status status-#{@subtask["status"]}"}>
|
||||
<%= String.upcase(@subtask["status"]) %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="item-description">
|
||||
<%= @subtask["description"] %>
|
||||
</div>
|
||||
|
||||
<div class="item-row">
|
||||
<span class="item-meta">
|
||||
<strong>Model:</strong> <%= @subtask["model"] %>
|
||||
</span>
|
||||
<span class="item-meta">
|
||||
<strong>Tier:</strong> <%= @subtask["tier_assigned"] %>
|
||||
</span>
|
||||
<span class="item-meta">
|
||||
<strong>Cost:</strong> $<%= format_cost(@subtask["cost"]) %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<%= if @subtask["started_at"] do %>
|
||||
<div class="item-row">
|
||||
<span class="item-meta">
|
||||
<strong>Started:</strong> <%= format_datetime(@subtask["started_at"]) %>
|
||||
</span>
|
||||
<%= if @subtask["completed_at"] do %>
|
||||
<span class="item-meta">
|
||||
<strong>Completed:</strong> <%= format_datetime(@subtask["completed_at"]) %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @subtask["result"] do %>
|
||||
<div class="item-result">
|
||||
<strong>Result:</strong>
|
||||
<pre><%= @subtask["result"] %></pre>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @subtask["depends_on"] && length(@subtask["depends_on"]) > 0 do %>
|
||||
<div class="item-deps">
|
||||
<strong>Depends on:</strong> <%= Enum.join(@subtask["depends_on"], ", ") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp task_history(assigns) do
|
||||
~H"""
|
||||
<div class="task-history">
|
||||
<div class="history-header">Recent Tasks</div>
|
||||
|
||||
<%= if length(@history) == 0 do %>
|
||||
<div class="history-empty">
|
||||
<p>No tasks yet. Create your first compound task above.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="history-list">
|
||||
<%= for task <- @history do %>
|
||||
<div class="history-item">
|
||||
<div class="history-item-header">
|
||||
<span class="history-item-id"><%= task["id"] %></span>
|
||||
<span class={"history-item-status status-#{task["status"]}"}>
|
||||
<%= String.upcase(task["status"]) %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="history-item-prompt">
|
||||
<%= String.slice(task["prompt"], 0..100) %>
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
<span><%= task["subtask_count"] || length(task["subtasks"] || []) %> subtasks</span>
|
||||
<span class="history-item-cost">
|
||||
$<%= format_cost(task["total_cost"]) %>
|
||||
</span>
|
||||
</div>
|
||||
<%= if @completion_summary do %>
|
||||
<div class="modal-completion">
|
||||
<span class="completion-label">Outcome</span>
|
||||
<p class="completion-text"><%= @completion_summary %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="modal-logs-section">
|
||||
<h4 class="logs-header">
|
||||
Activity Log
|
||||
<span class="logs-count"><%= length(@logs) %> entries</span>
|
||||
</h4>
|
||||
<%= if @loading do %>
|
||||
<div class="logs-loading">Loading...</div>
|
||||
<% else %>
|
||||
<%= if @logs == [] do %>
|
||||
<p class="logs-empty">No log entries recorded for this session.</p>
|
||||
<% else %>
|
||||
<div class="logs-timeline">
|
||||
<%= for log <- @logs do %>
|
||||
<div class="log-entry">
|
||||
<span class="log-time"><%= fmt_time(gv(log, "timestamp", "")) %></span>
|
||||
<span class="log-text"><%= gv(log, "entry", "") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Helpers --
|
||||
# ── Data Fetching ──
|
||||
|
||||
defp submit_compound_task(prompt) do
|
||||
case Req.post("#{symbiont_url()}/task/compound",
|
||||
json: %{"prompt" => prompt},
|
||||
headers: [{"authorization", "Bearer #{@auth_token}"}]
|
||||
) do
|
||||
{:ok, response} ->
|
||||
case response.status do
|
||||
200 -> {:ok, response.body}
|
||||
_ -> {:error, "HTTP #{response.status}"}
|
||||
defp fetch_engram_data do
|
||||
stats_task =
|
||||
Task.async(fn ->
|
||||
case Req.get("#{symbiont_url()}/engram/stats", receive_timeout: @http_timeout) do
|
||||
{:ok, %{status: 200, body: body}} when is_map(body) -> body
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_task_progress(task_id) do
|
||||
case Req.get("#{symbiont_url()}/task/#{task_id}/progress") do
|
||||
{:ok, response} ->
|
||||
case response.status do
|
||||
200 -> {:ok, response.body}
|
||||
_ -> {:error, "HTTP #{response.status}"}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_recent_tasks do
|
||||
case Req.get("#{symbiont_url()}/tasks/recent") do
|
||||
{:ok, response} ->
|
||||
case response.status do
|
||||
200 ->
|
||||
case response.body do
|
||||
list when is_list(list) -> list
|
||||
_ -> []
|
||||
end
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_waves(subtasks) do
|
||||
# Group subtasks by dependency level (wave)
|
||||
# Wave 0: no dependencies, Wave N: max(dep waves) + 1
|
||||
# Sort by index first to ensure deps are computed before dependents
|
||||
sorted = Enum.sort_by(subtasks, & &1["index"])
|
||||
|
||||
wave_map =
|
||||
Enum.reduce(sorted, %{}, fn task, acc ->
|
||||
deps = task["depends_on"] || []
|
||||
|
||||
wave =
|
||||
if Enum.empty?(deps) do
|
||||
0
|
||||
else
|
||||
deps
|
||||
|> Enum.map(&Map.get(acc, &1, 0))
|
||||
|> Enum.max(fn -> 0 end)
|
||||
|> Kernel.+(1)
|
||||
end
|
||||
|
||||
Map.put(acc, task["index"], wave)
|
||||
end)
|
||||
|
||||
subtasks
|
||||
|> Enum.group_by(&Map.get(wave_map, &1["index"], 0))
|
||||
|> Enum.sort_by(fn {wave, _} -> wave end)
|
||||
|> Enum.map(fn {_, tasks} -> Enum.sort_by(tasks, &(&1["index"])) end)
|
||||
sessions_task =
|
||||
Task.async(fn ->
|
||||
case Req.get("#{symbiont_url()}/engram/sessions/recent?hours=8760",
|
||||
receive_timeout: @http_timeout
|
||||
) do
|
||||
{:ok, %{status: 200, body: %{"sessions" => s}}} when is_list(s) -> s
|
||||
_ -> []
|
||||
end
|
||||
end)
|
||||
|
||||
stats = Task.await(stats_task, @http_timeout + 1_000)
|
||||
sessions = Task.await(sessions_task, @http_timeout + 1_000)
|
||||
{stats, sessions}
|
||||
end
|
||||
|
||||
defp format_cost(cost) when is_number(cost) do
|
||||
:io_lib.format("~.4f", [cost]) |> to_string()
|
||||
end
|
||||
# ── Helpers ──
|
||||
|
||||
defp format_cost(_), do: "0.0000"
|
||||
defp gv(map, key, default \\ nil) when is_map(map), do: Map.get(map, key, default)
|
||||
defp gv(_, _, default), do: default
|
||||
|
||||
defp format_datetime(iso_string) when is_binary(iso_string) do
|
||||
case DateTime.from_iso8601(iso_string) do
|
||||
{:ok, dt, _} ->
|
||||
dt
|
||||
|> DateTime.to_naive()
|
||||
|> NaiveDateTime.to_string()
|
||||
defp fmt_date(""), do: "\u2014"
|
||||
|
||||
{:error, _} ->
|
||||
iso_string
|
||||
defp fmt_date(iso) when is_binary(iso) do
|
||||
case parse_dt(iso) do
|
||||
{:ok, dt} ->
|
||||
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||
days = div(diff, 86400)
|
||||
|
||||
cond do
|
||||
days == 0 -> "today"
|
||||
days == 1 -> "yesterday"
|
||||
days < 7 -> "#{days}d ago"
|
||||
true -> Calendar.strftime(dt, "%b %d")
|
||||
end
|
||||
|
||||
_ ->
|
||||
String.slice(iso, 0, 10)
|
||||
end
|
||||
end
|
||||
|
||||
defp format_datetime(nil), do: "—"
|
||||
defp fmt_date(_), do: "\u2014"
|
||||
|
||||
defp fmt_full(nil), do: "\u2014"
|
||||
defp fmt_full(""), do: "\u2014"
|
||||
|
||||
defp fmt_full(iso) when is_binary(iso) do
|
||||
case parse_dt(iso) do
|
||||
{:ok, dt} -> Calendar.strftime(dt, "%b %d, %Y at %H:%M UTC")
|
||||
_ -> iso
|
||||
end
|
||||
end
|
||||
|
||||
defp fmt_full(_), do: "\u2014"
|
||||
|
||||
defp fmt_time(""), do: ""
|
||||
|
||||
defp fmt_time(iso) when is_binary(iso) do
|
||||
case parse_dt(iso) do
|
||||
{:ok, dt} -> Calendar.strftime(dt, "%H:%M")
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp fmt_time(_), do: ""
|
||||
|
||||
defp parse_dt(iso) do
|
||||
case DateTime.from_iso8601(iso) do
|
||||
{:ok, dt, _} -> {:ok, dt}
|
||||
_ ->
|
||||
case NaiveDateTime.from_iso8601(iso) do
|
||||
{:ok, ndt} -> {:ok, DateTime.from_naive!(ndt, "Etc/UTC")}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user