diff --git a/Gemfile b/Gemfile index 9065f6d..5c4389a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 8.1.0" +gem "rails", "~> 8.1.3", ">= 8.1.3.1" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use postgresql as the database for Active Record diff --git a/Gemfile.lock b/Gemfile.lock index 2d8cc56..b9a28e7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,31 +1,31 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.18) + action_text-trix (2.1.19) railties - actioncable (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + actioncable (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionmailbox (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) mail (>= 2.8.0) - actionmailer (8.1.3) - actionpack (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activesupport (= 8.1.3) + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.3) - actionview (= 8.1.3) - activesupport (= 8.1.3) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,36 +33,36 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.3) + actiontext (8.1.3.1) action_text-trix (~> 2.1.15) - actionpack (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionpack (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.3) - activesupport (= 8.1.3) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.3) - activesupport (= 8.1.3) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) globalid (>= 0.3.6) - activemodel (8.1.3) - activesupport (= 8.1.3) - activerecord (8.1.3) - activemodel (= 8.1.3) - activesupport (= 8.1.3) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) timeout (>= 0.4.0) - activestorage (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activesupport (= 8.1.3) + activestorage (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activesupport (= 8.1.3.1) marcel (~> 1.0) - activesupport (8.1.3) + activesupport (8.1.3.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -189,13 +189,13 @@ GEM loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.9.0) + mail (2.9.1) logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) matrix (0.4.3) mini_magick (5.3.1) logger @@ -300,20 +300,20 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (8.1.3) - actioncable (= 8.1.3) - actionmailbox (= 8.1.3) - actionmailer (= 8.1.3) - actionpack (= 8.1.3) - actiontext (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activemodel (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) bundler (>= 1.15.0) - railties (= 8.1.3) + railties (= 8.1.3.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -326,9 +326,9 @@ GEM nio4r railties websocket-driver - railties (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + railties (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -498,7 +498,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rack-attack - rails (~> 8.1.0) + rails (~> 8.1.3, >= 8.1.3.1) rails_live_reload redcarpet rubocop-rails-omakase diff --git a/app/controllers/control_room_controller.rb b/app/controllers/control_room_controller.rb index 9a729f1..3102e24 100644 --- a/app/controllers/control_room_controller.rb +++ b/app/controllers/control_room_controller.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class ControlRoomController < ApplicationController + PROGRAM_KINDS = %w[bot-fix qa-oversight].freeze + QUEUED_STATES = %w[queued ready].freeze + WAITING_STATES = %w[failed review].freeze + TERMINAL_STATES = %w[done cancelled].freeze + before_action :set_task, only: %i[thread message approve retry cancel] helper_method :intent_result_summary, :pane_display_status, :orchestration_items, :task_needs_attention? @@ -116,12 +121,48 @@ def intent_result_summary(intent) end def categorize_tasks - @needs_attention = @tasks.select { |task| task_needs_attention?(task) } - @failures = @tasks.select { |task| source_state(task) == "failed" } - @active = @tasks.select do |task| - %w[queued ready running review].include?(source_state(task)) && !task_needs_attention?(task) + grouped = @tasks.group_by { |task| task_lane(task) } + @waiting_on_you = grouped.fetch(:waiting, []) + @running_now = grouped.fetch(:running, []) + @queued_next = grouped.fetch(:queued, []) + @programs = grouped.fetch(:program, []) + @unclassified_tasks = grouped.fetch(:unclassified, []) + @recent = grouped.fetch(:recent, []).first(25) + @project_work_counts = project_work_counts + end + + def task_lane(task) + return :recent if TERMINAL_STATES.include?(source_state(task)) + return :program if program_task?(task) + return :waiting if task_needs_attention?(task) || WAITING_STATES.include?(source_state(task)) + return :running if source_state(task) == "running" + return :queued if QUEUED_STATES.include?(source_state(task)) + + :unclassified + end + + def program_task?(task) + state = task.state_data.fetch("orchestration", {}) + state["work_type"] == "program" || PROGRAM_KINDS.include?(state["kind"]) + end + + def project_work_counts + lanes = { + waiting: @waiting_on_you, + running: @running_now, + queued: @queued_next, + program: @programs, + unclassified: @unclassified_tasks + } + projects = lanes.values.flatten.filter_map { |task| task_project(task) }.uniq.sort + + projects.to_h do |project| + [project, lanes.transform_values { |tasks| tasks.count { |task| task_project(task) == project } }] end - @recent = @tasks.select { |task| %w[done cancelled].include?(source_state(task)) }.first(25) + end + + def task_project(task) + task.state_data.dig("orchestration", "project").to_s.presence end def selected_task diff --git a/app/javascript/controllers/control_room_live_controller.js b/app/javascript/controllers/control_room_live_controller.js index 0e459d4..e37f492 100644 --- a/app/javascript/controllers/control_room_live_controller.js +++ b/app/javascript/controllers/control_room_live_controller.js @@ -36,7 +36,21 @@ export default class extends Controller { const name = region.dataset.controlRoomLiveRegion const current = [...this.element.querySelectorAll("[data-control-room-live-region]")] .find((candidate) => candidate.dataset.controlRoomLiveRegion === name) - if (current) current.innerHTML = region.innerHTML + if (current) { + this.syncRegionAttributes(current, region) + current.innerHTML = region.innerHTML + } + }) + } + + syncRegionAttributes(current, next) { + const names = [ + "class", "role", "data-source-count", "data-rendered-count", + "data-open-source-count", "data-unclassified-count" + ] + names.forEach((name) => { + if (next.hasAttribute(name)) current.setAttribute(name, next.getAttribute(name)) + else current.removeAttribute(name) }) } diff --git a/app/views/control_room/_fleet.html.erb b/app/views/control_room/_fleet.html.erb index 8d1bcc8..de6856f 100644 --- a/app/views/control_room/_fleet.html.erb +++ b/app/views/control_room/_fleet.html.erb @@ -1,30 +1,63 @@ -
-
-
-

Fleet

-

- <% if @source&.last_seen_at %> - <%= @source.panes.length %> visible panes · last sync <%= time_ago_in_words(@source.last_seen_at) %> ago - <% else %> - No bridge telemetry yet - <% end %> -

+<% panes = Array(@source&.panes) %> +<% status_counts = panes.map { |pane| pane_display_status(pane) }.tally %> +
+ +
+ Fleet · <%= panes.length %> panes + <% { + "working" => ["bg-green-400", "text-green-300"], + "waiting" => ["bg-yellow-400", "text-yellow-200"], + "permission" => ["bg-yellow-400", "text-yellow-200"], + "idle" => ["bg-sky-400", "text-sky-300"], + "present" => ["bg-violet-400", "text-violet-300"], + "unknown" => ["bg-slate-400", "text-slate-300"] + }.each do |status, classes| %> + <% count = status_counts.fetch(status, 0) %> + <% next unless count.positive? %> + + "> + <%= count %> <%= status %> + + <% end %>
- <% if @source&.health&.dig("fleet") %> - <% a2a_updates = @source.health.dig("fleet", "a2a_envelopes").to_i %> - - <%= a2a_updates.positive? ? "#{a2a_updates} A2A updates in latest sync" : "No A2A updates in latest sync" %> - - <% end %> -
- <% if @source&.panes&.any? %> -
- <% @source.panes.each do |pane| %> -
- pane <%= pane["pane_id"] || pane["id"] %> - <%= pane["project"] %> · <%= pane_display_status(pane) %> + + <% if @source&.last_seen_at %> + <% a2a_updates = @source.health.to_h.dig("fleet", "a2a_envelopes").to_i %> + synced <%= time_ago_in_words(@source.last_seen_at) %> ago · + <%= a2a_updates.positive? ? "#{a2a_updates} A2A updates in latest sync" : "No A2A updates in latest sync" %> · + expand pane details + <% else %> + no bridge telemetry · expand + <% end %> + + + + <% if panes.any? %> +
+ <% panes.each do |pane| %> + <% status = pane_display_status(pane) %> + <% status_style = case status + when "working" then "border-green-400/35 bg-green-400/10 text-green-300" + when "waiting", "permission" then "border-yellow-400/40 bg-yellow-400/10 text-yellow-200" + when "idle" then "border-sky-400/30 bg-sky-400/10 text-sky-300" + when "present" then "border-violet-400/30 bg-violet-400/10 text-violet-300" + else "border-border bg-bg-elevated text-content-muted" + end %> +
, <%= pane["project"] %>, <%= status %>"> +
+ pane <%= pane["pane_id"] || pane["id"] %> · <%= pane["project"] %> + + "> + <%= status %> + +
<% if pane["title"].present? || pane["model"].present? || pane["ctx"].present? %> -

+

<%= [pane["title"], pane["persona"], pane["model"], pane["ctx"]].compact_blank.join(" · ") %>

<% end %> @@ -33,8 +66,8 @@ Session <%= pane["session_pct"].presence || "—" %>% · week <%= pane["weekly_pct"].presence || "—" %>%

<% end %> -
+ <% end %>
<% end %> -
+ diff --git a/app/views/control_room/_live_payload.html.erb b/app/views/control_room/_live_payload.html.erb index 24c9ea8..fcb034b 100644 --- a/app/views/control_room/_live_payload.html.erb +++ b/app/views/control_room/_live_payload.html.erb @@ -1,4 +1,3 @@ -<%= render "source_badges" %> <%= render "source_health" %> <%= render "fleet" %> <%= render "request_status" %> diff --git a/app/views/control_room/_request_status.html.erb b/app/views/control_room/_request_status.html.erb index c5dce12..b986e2a 100644 --- a/app/views/control_room/_request_status.html.erb +++ b/app/views/control_room/_request_status.html.erb @@ -1,18 +1,23 @@ -
-
+
+
-

Recent requests

-

What the PC bridge accepted, rejected, or is still processing.

+ Delivery log + Receipts only · expand
<% if @pending_intents.any? %> <%= pluralize(@pending_intents.size, "pending") %> + <% else %> + <%= @recent_intents.size %> recent <% end %> -
+ <% if @recent_intents.any? %> -
+
<% @recent_intents.each do |intent| %> <% tone = { "applied" => "border-green-400/25 text-green-400", "rejected" => "border-red-400/25 text-red-400" }.fetch(intent.status, "border-accent/25 text-accent") %>
@@ -28,6 +33,6 @@ <% end %>
<% else %> -

No requests have been sent yet.

+

No requests have been sent yet.

<% end %> -
+ diff --git a/app/views/control_room/_source_health.html.erb b/app/views/control_room/_source_health.html.erb index 47ac731..6d00070 100644 --- a/app/views/control_room/_source_health.html.erb +++ b/app/views/control_room/_source_health.html.erb @@ -4,13 +4,27 @@ <% last_error = @source&.last_error.presence %> <% warning = stale || last_error.present? %>
" + class="rounded-xl border px-4 py-3 <%= warning ? "border-red-400/40 bg-red-400/10" : "border-green-400/25 bg-green-400/5" %>" role="<%= warning ? "alert" : "status" %>"> +
+
+ <% if @source %> + "> + <%= @source.profile %> · <%= warning ? "attention" : @source.display_status %> + + + bridge <%= @source.last_seen_at ? "synced #{time_ago_in_words(@source.last_seen_at)} ago" : "has not synced yet" %> + + <% else %> + Waiting for the Wezbridge bridge + <% end %> +
+ " + data-control-room-live-target="status" + aria-live="polite">Page refresh connecting… +
<% if warning %> -

- <%= stalled ? "Orchestration bridge is stalled" : "Orchestration updates are delayed" %> -

-

+

This board may be showing old state. <% if health["stale_seconds"].present? %> No progress for <%= distance_of_time_in_words(health["stale_seconds"].to_i) %>. @@ -19,9 +33,9 @@ <% else %> No successful sync has been recorded. <% end %> + <% if last_error %> + Bridge error: <%= last_error %> + <% end %>

- <% if last_error %> -

Bridge error: <%= last_error %>

- <% end %> <% end %>
diff --git a/app/views/control_room/_task_board.html.erb b/app/views/control_room/_task_board.html.erb index af5576d..61226e1 100644 --- a/app/views/control_room/_task_board.html.erb +++ b/app/views/control_room/_task_board.html.erb @@ -1,6 +1,100 @@ -
- <%= render "task_list", title: "Needs Attention", tasks: @needs_attention, tone: "yellow" %> - <%= render "task_list", title: "Active Work", tasks: @active, tone: "blue" %> - <%= render "task_list", title: "Failures", tasks: @failures, tone: "red" %> - <%= render "task_list", title: "Recently Completed", tasks: @recent, tone: "green" %> +
+
+
+
+

Work by project

+

Counts describe the actual lane, not a generic “active” state.

+
+

+ <%= @waiting_on_you.length + @running_now.length + @queued_next.length + @programs.length + @unclassified_tasks.length %> open source rows · + <%= @unclassified_tasks.length %> unclassified +

+
+
+ <% @project_work_counts.each do |project, counts| %> + <% details = counts.filter_map { |lane, count| "#{count} #{lane}" if count.positive? } %> + + <%= project %>: <%= details.join(" · ") %> + + <% end %> + <% if @project_work_counts.empty? %> +

No open work is mirrored.

+ <% end %> +
+
+ + <% if @unclassified_tasks.any? %> +
+ + <%= pluralize(@unclassified_tasks.length, "task") %> could not be classified · show + +

+ This is a cockpit classification gap, not work you owe. The tasks remain visible here until their source state is understood. +

+
+ <% @unclassified_tasks.first(20).each do |task| %> + <% state = task.state_data.fetch("orchestration", {}) %> + <%= link_to control_room_path(task_id: task.id), + class: "rounded-lg border border-yellow-500/30 bg-bg-elevated p-3 text-sm text-content hover:border-yellow-400/60", + data: { task_origin_id: task.origin_session_id } do %> + <%= task.name %> + + <%= state["project"] %> · source state “<%= state["source_state"].presence || "missing" %>” + + <% end %> + <% end %> +
+
+ <% end %> + +
+ <%= render "task_list", + title: "Waiting on You", + description: "Decisions, replies, reviews, and retries that genuinely need you.", + empty_message: "Nothing needs your input.", + tasks: @waiting_on_you, + show_action: true %> + <%= render "task_list", + title: "Running Now", + description: "Work with an active executor right now.", + empty_message: "No executor is running a task.", + tasks: @running_now, + show_action: false %> + <%= render "task_list", + title: "Queued Next", + description: "Accepted work waiting for an executor.", + empty_message: "Nothing is waiting to start.", + tasks: @queued_next, + show_action: false %> + <%= render "task_list", + title: "Programs", + description: "Long-running coordination and oversight.", + empty_message: "No ongoing programs.", + tasks: @programs, + show_action: false %> +
+ +
+ + Recently completed · <%= @recent.length %> + history · expand + +
+ <%= render "task_list", + title: "Recently Completed", + description: "Finished and cancelled work, kept for reference.", + empty_message: "No recent completed work.", + tasks: @recent, + show_action: false %> +
+
diff --git a/app/views/control_room/_task_list.html.erb b/app/views/control_room/_task_list.html.erb index 83ac6b2..2850b6f 100644 --- a/app/views/control_room/_task_list.html.erb +++ b/app/views/control_room/_task_list.html.erb @@ -3,15 +3,18 @@ data-source-count="<%= tasks.length %>" data-rendered-count="<%= [tasks.length, 20].min %>">
-

<%= title %>

+
+

<%= title %>

+

<%= description %>

+
<%= tasks.length > 20 ? "#{[tasks.length, 20].min} of #{tasks.length}" : tasks.length %>
-
+
<% tasks.first(20).each do |task| %> <% state = task.state_data.fetch("orchestration", {}) %> - <%= link_to control_room_path(task_id: task.id, anchor: "task-thread"), + <%= link_to control_room_path(task_id: task.id), class: "block rounded-lg border border-border bg-bg-elevated p-3 transition-colors hover:border-accent/40", data: { task_origin_id: task.origin_session_id } do %>

<%= task.name %>

@@ -20,13 +23,13 @@ <%= state["source_state"] %>
<%= render "task_context", state:, compact: true %> - <% if task_needs_attention?(task) %> + <% if show_action %>

Open and answer

<% end %> <% end %> <% end %> <% if tasks.empty? %> -

Nothing here.

+

<%= empty_message %>

<% end %>
diff --git a/app/views/control_room/show.html.erb b/app/views/control_room/show.html.erb index 5880c23..0e7e1bd 100644 --- a/app/views/control_room/show.html.erb +++ b/app/views/control_room/show.html.erb @@ -1,68 +1,36 @@ <% content_for :title, "Control Room" %> <% content_for :breadcrumb_standalone, "Control Room" %> -
" data-controller="control-room-live" data-control-room-live-url-value="<%= control_room_live_path(source_id: @source&.id) %>" data-control-room-live-interval-value="5000"> -
+

Personal orchestration

Control Room

-

- Create work, talk to the orchestrator, and watch the local fleet without managing every terminal tab. -

-

Connecting live cockpit…

- - <%= render "source_badges" %> +

+ See what needs you, what is running, and what pane 0 reported—without watching every terminal. +

<%= render "source_health" %> <%= render "fleet" %> + <%= render "task_board" %> -
-
-

New Task

-

- Record validated work in the durable ledger. Pane 0 dispatches it to a worker. -

- <%= form_with url: control_room_tasks_path, class: "mt-4 grid gap-3" do |form| %> - <%= form.hidden_field :source_id, value: @source&.id, id: "task_source_id" %> +
+
+
- <%= form.label :project, "Work context", for: "task_project", class: "mb-1 block text-xs font-medium text-content-secondary" %> - <%= form.select :project, @project_options, { prompt: "Choose a live pane / project" }, - id: "task_project", - required: true, - class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> -
- <%= form.label :title, "Task title", for: "task_title", class: "sr-only" %> - <%= form.text_field :title, id: "task_title", placeholder: "Task title", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.label :brief, "Task brief", for: "task_brief", class: "sr-only" %> - <%= form.text_area :brief, id: "task_brief", required: true, rows: 4, placeholder: "What should be done?", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.label :acceptance, "Acceptance criteria", for: "task_acceptance", class: "sr-only" %> - <%= form.text_area :acceptance, id: "task_acceptance", rows: 2, placeholder: "What does done look like?", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> -
- <%= form.label :priority, "Priority", for: "task_priority", class: "sr-only" %> - <%= form.select :priority, [["Normal", "normal"], ["High", "high"], ["Low", "low"]], {}, - id: "task_priority", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.submit "Create task", disabled: @source.blank?, - class: "cursor-pointer rounded-lg bg-accent px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" %> +

Primary action

+

Talk to pane 0

+

+ Ask about one project or the whole fleet. The answer arrives in the task drawer. +

- <% end %> -
- -
-

Ask Orchestrator

-

- Ask pane 0 about one project or the whole live fleet. The answer appears in the task thread. -

- <%= form_with url: control_room_ask_path, class: "mt-4 grid gap-3" do |form| %> + Creates a question thread +
+ <%= form_with url: control_room_ask_path, class: "mt-4 grid gap-3 lg:grid-cols-[minmax(12rem,18rem)_minmax(0,1fr)_auto]" do |form| %> <%= form.hidden_field :source_id, value: @source&.id, id: "question_source_id" %>
<%= form.label :project, "Question context", for: "question_project", class: "mb-1 block text-xs font-medium text-content-secondary" %> @@ -70,61 +38,125 @@ id: "question_project", class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %>
- <%= form.label :title, "Question title", for: "question_title", class: "sr-only" %> - <%= form.text_field :title, id: "question_title", placeholder: "Question title", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> - <%= form.label :brief, "Question", for: "question_brief", class: "sr-only" %> - <%= form.text_area :brief, id: "question_brief", required: true, rows: 5, placeholder: "What do you want to discuss?", - class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> -
- <%= form.submit "Ask", disabled: @source.blank?, - class: "cursor-pointer rounded-lg bg-accent px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" %> +
+ <%= form.label :title, "Question title", for: "question_title", class: "mb-1 block text-xs font-medium text-content-secondary" %> + <%= form.text_field :title, id: "question_title", placeholder: "What is this about?", + class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> +
+ <%= form.submit "Ask pane 0", disabled: @source.blank?, + class: "mt-5 cursor-pointer self-start rounded-lg bg-accent px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" %> +
+ <%= form.label :brief, "Question", for: "question_brief", class: "sr-only" %> + <%= form.text_area :brief, id: "question_brief", required: true, rows: 3, + placeholder: "What do you want pane 0 to assess, explain, or decide?", + class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %>
<% end %>
-
- <%= render "request_status" %> - <%= render "task_board" %> +
+
+ + Create work + secondary action · expand + +
+

Record validated work in the durable ledger for pane 0 to dispatch.

+ <%= form_with url: control_room_tasks_path, class: "mt-3 grid gap-3" do |form| %> + <%= form.hidden_field :source_id, value: @source&.id, id: "task_source_id" %> + <%= form.label :project, "Work context", for: "task_project", class: "sr-only" %> + <%= form.select :project, @project_options, { prompt: "Choose a live pane / project" }, + id: "task_project", + required: true, + class: "w-full rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.label :title, "Task title", for: "task_title", class: "sr-only" %> + <%= form.text_field :title, id: "task_title", placeholder: "Task title", + class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.label :brief, "Task brief", for: "task_brief", class: "sr-only" %> + <%= form.text_area :brief, id: "task_brief", required: true, rows: 3, placeholder: "What should be done?", + class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.label :acceptance, "Acceptance criteria", for: "task_acceptance", class: "sr-only" %> + <%= form.text_area :acceptance, id: "task_acceptance", rows: 2, placeholder: "What does done look like?", + class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> +
+ <%= form.label :priority, "Priority", for: "task_priority", class: "sr-only" %> + <%= form.select :priority, [["Normal", "normal"], ["High", "high"], ["Low", "low"]], {}, + id: "task_priority", + class: "rounded-lg border border-border bg-bg-elevated px-3 py-2 text-content" %> + <%= form.submit "Create task", disabled: @source.blank?, + class: "cursor-pointer rounded-lg bg-accent px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" %> +
+ <% end %> +
+
+ + <%= render "request_status" %> +
+
<% if @selected_task %> <% state = @selected_task.state_data.fetch("orchestration", {}) %> -
-
-
-

<%= state["project"] %> · <%= state["source_state"] %>

-

<%= @selected_task.name %>

-

<%= @selected_task.description %>

- <%= render "task_context", state:, compact: false %> -

Live updates on

+ <%# Cockpit boundary: quick decisions only. Rich task detail belongs exclusively to the existing Boards task panel. %> +
+ <% end %> + <%= turbo_frame_tag "task_panel" %>
diff --git a/test/controllers/control_room_controller_test.rb b/test/controllers/control_room_controller_test.rb index 03d9ab9..bbd94ca 100644 --- a/test/controllers/control_room_controller_test.rb +++ b/test/controllers/control_room_controller_test.rb @@ -76,7 +76,7 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_select "main#main-content.w-full.max-w-none" assert_select "[data-controller='control-room-live']", count: 1 assert_select "[data-control-room-live-region='requests']", count: 1 - assert_select "h2", "Recent requests" + assert_select "summary", text: /Delivery log/ assert_select "[data-controller='gateway-health']", count: 0 assert_select "select#task_project", count: 1 assert_select "select#question_project", count: 1 @@ -87,6 +87,19 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_select "option[value='omniremote']", text: /present/, count: 2 assert_includes response.body, "No A2A updates in latest sync" assert_includes response.body, "Projected task" + assert_select "[data-control-room-live-region='fleet'][data-source-count='3'][data-rendered-count='3']" + assert_select "[data-pane-status='working']", count: 1 + assert_select "[data-pane-status='idle']", count: 1 + assert_select "[data-pane-status='present']", count: 1 + assert_select "aside#task-thread[role='complementary'][data-persistent-drawer='true']", count: 1 + assert_select "#task-thread[aria-modal]", count: 0 + assert_select "#task-thread a[href='#{board_task_path(task.board, task)}'][data-turbo-frame='task_panel']", + text: "Open full task" + assert_select "turbo-frame#task_panel", count: 1 + assert_select "#task-thread [data-task-context='full']", count: 0 + assert_select "#task-thread #task-thread-messages", count: 0 + projected_link = css_select("[data-task-origin-id='#{task.origin_session_id}']").sole + assert_not_includes projected_link["href"], "#task-thread" ids = css_select("[id]").map { |element| element["id"] } assert_equal ids.uniq, ids, "Control Room must not render duplicate HTML ids" @@ -119,8 +132,9 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest get control_room_live_path(source_id: @source.id) assert_response :success - assert_select "[data-control-room-live-region]", count: 5 - assert_select "[data-control-room-live-region='requests']", text: /##{applied.id} Message/ + assert_select "[data-control-room-live-region]", count: 4 + assert_select "[data-control-room-live-region='requests'][data-source-count='2'][data-rendered-count='2']", + text: /##{applied.id} Message/ assert_includes response.body, "Delivered to the ledger" assert_includes response.body, "Task is already complete" end @@ -158,7 +172,7 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest get control_room_path(task_id: blocked.id) assert_response :success - attention = css_select("[data-board-section='needs-attention']").sole + attention = css_select("[data-board-section='waiting-on-you']").sole assert_equal "3", attention["data-source-count"] assert_equal "3", attention["data-rendered-count"] assert_equal 1, attention.css("[data-task-origin-id='#{blocked.origin_session_id}']").count @@ -179,12 +193,80 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_not_includes blocked_card.text, "20 balances affect current members" assert_equal 3, blocked_card.css("[data-task-context='compact'] .line-clamp-2").count - full_context = css_select("#task-thread [data-task-context='full']").sole - assert_includes full_context.text, "Decision is recorded" - assert_includes full_context.text, "20 balances affect current members" + assert_select "#task-thread [data-task-context='full']", count: 0 + assert_select "#task-thread", text: /Operator must decide whether to dispose ARS 8,025,812\.27/ + assert_select "#task-thread", text: /Answer with approve or retain/ + assert_not_includes css_select("#task-thread").sole.text, "Decision is recorded" + assert_not_includes css_select("#task-thread").sole.text, "20 balances affect current members" assert_select "#task-thread form[action='#{control_room_task_messages_path(blocked)}']", count: 1 end + test "splits open work into disjoint truthful lanes with operational project counts" do + waiting = projected_task_with(id: "T-0101", name: "Needs a reply", state: "review") + running = projected_task_with(id: "T-0102", name: "Executing now", state: "running") + queued = projected_task_with(id: "T-0103", name: "Starts next", state: "ready") + program = projected_task_with( + id: "T-0104", + name: "Long-running quality loop", + state: "queued", + kind: "bot-fix" + ) + explicit_program = projected_task_with( + id: "T-0105", + name: "Explicit program", + state: "running", + kind: "task", + work_type: "program", + project: "mutual" + ) + completed = projected_task_with( + id: "T-0106", + name: "Finished", + state: "done", + status: :done + ) + unclassified = projected_task_with( + id: "T-0107", + name: "Unknown source state", + state: "paused" + ) + sign_in_as(@user) + + get control_room_path + + assert_response :success + expected = { + "waiting-on-you" => [waiting], + "running-now" => [running], + "queued-next" => [queued], + "programs" => [program, explicit_program], + "unclassified" => [unclassified], + "recently-completed" => [completed] + } + rendered_ids = expected.flat_map do |section_name, tasks| + section = css_select("[data-board-section='#{section_name}']").sole + assert_equal tasks.length.to_s, section["data-source-count"], section_name + assert_equal tasks.length.to_s, section["data-rendered-count"], section_name + ids = section.css("[data-task-origin-id]").map { |card| card["data-task-origin-id"] } + assert_equal tasks.map(&:origin_session_id).sort, ids.sort, section_name + ids + end + assert_equal rendered_ids.uniq.sort, rendered_ids.sort, "a task must render in one lane only" + + board = css_select("[data-control-room-live-region='task-board']").sole + assert_equal "6", board["data-open-source-count"] + assert_equal "1", board["data-unclassified-count"] + assert_includes css_select("[data-board-section='unclassified']").sole.text, + "classification gap, not work you owe" + summary = css_select("[data-board-section='project-work-summary']").sole + assert_equal "2", summary["data-source-count"] + assert_equal "2", summary["data-rendered-count"] + assert_includes summary.text, + "whatsappbot: 1 waiting · 1 running · 1 queued · 1 program · 1 unclassified" + assert_includes summary.text, "mutual: 1 program" + assert_includes summary.text, "1 unclassified" + end + test "renders a loud stalled bridge warning with its error" do @source.update!( last_seen_at: 5.minutes.ago, @@ -197,11 +279,10 @@ class ControlRoomControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "[data-control-room-live-region='source-health'][role='alert']", count: 1 do - assert_select "h2", "Orchestration bridge is stalled" + assert_select "span", text: /primary · attention/ assert_select "p", text: /board may be showing old state/i assert_select "p", text: /sync HTTP 503/ end - assert_select "[data-control-room-live-region='source-badges']", text: /attention/ end test "live cockpit requires authentication" do @@ -252,7 +333,10 @@ def projected_task ) end - def projected_task_with(id:, name:, state:, kind: "task", status: :up_next, needs_decision: false) + def projected_task_with( + id:, name:, state:, kind: "task", status: :up_next, needs_decision: false, + project: "whatsappbot", work_type: nil + ) @user.tasks.create!( board: boards(:one), name:, @@ -265,8 +349,9 @@ def projected_task_with(id:, name:, state:, kind: "task", status: :up_next, need "orchestration" => { "profile" => "primary", "source_state" => state, - "project" => "whatsappbot", - "kind" => kind + "project" => project, + "kind" => kind, + "work_type" => work_type } } ) diff --git a/test/system/control_room_test.rb b/test/system/control_room_test.rb index 825716d..07d432d 100644 --- a/test/system/control_room_test.rb +++ b/test/system/control_room_test.rb @@ -40,11 +40,13 @@ class ControlRoomTest < ApplicationSystemTestCase assert_text "Control Room" assert_text "pane 0" - within("[data-board-section='needs-attention']") do + within("[data-board-section='waiting-on-you']") do assert_text "Operator ruling required before execution." assert_text(/gate/i) assert_text "operator" end + assert_selector "#task-thread[role='complementary'][data-persistent-drawer='true']" + find("summary", text: "Create work").click within("form[action='#{control_room_tasks_path}']") do select "pane 0 — wezbridge (idle)", from: "Work context" fill_in "title", with: "Ship a focused fix" @@ -70,7 +72,7 @@ class ControlRoomTest < ApplicationSystemTestCase assert_equal "_fleet", find_field("Question context").value fill_in "Question title", with: "Assess every open pane" fill_in "Question", with: "Recommend the next action for every live project." - click_button "Ask" + click_button "Ask pane 0" end assert_text "Question queued as intent" @@ -79,24 +81,18 @@ class ControlRoomTest < ApplicationSystemTestCase assert_equal "question", intent.payload["kind"] end - test "shows a new orchestrator reply without reloading the page" do - visit control_room_path(task_id: @task.id, anchor: "task-thread") - assert_text "Live updates on" + test "opens the existing full task panel from the quick reply drawer" do + visit control_room_path(task_id: @task.id) - @task.agent_messages.create!( - direction: "incoming", - message_type: "output", - content: "Reply appeared through live refresh.", - sender_name: "pane 0" - ) + within("#task-thread") { click_link "Open full task" } - within("#task-thread-messages") do - assert_text "Reply appeared through live refresh.", wait: 8 - end + assert_selector "[data-controller~='task-modal']", wait: 8 + assert_text "Review the canary" end test "refreshes cockpit state without clearing a draft" do visit control_room_path + find("summary", text: "Create work").click fill_in "Task title", with: "Keep this draft" @user.tasks.create!(