diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md
index f145400..1c17e77 100644
--- a/docs/PROJECT_STATUS.md
+++ b/docs/PROJECT_STATUS.md
@@ -229,17 +229,17 @@ browser/TUI smoke verification.
- [x] Ability to start webserver (`tycho serve`)
- [x] Tailscale auto-bind, MagicDNS URL display, HTTPS Serve detection, and compact terminal QR for phone setup
-- [x] Mobile Remote UI shell at `/` with `Now`, `Agents`, `Search`, `Projects`, and `Setup` tabs
+- [x] Mobile Remote UI shell at `/` with simplified `Now`, `Agents`, and `Settings` tabs
- [x] Footer navigation sticks to the viewport, hides on downward scroll, and reappears on upward scroll
- [x] Read agent conversation (`GET /agents/{key}/conversation`)
- [x] Submit prompt to agent (`POST /agents/{key}/messages`)
- [x] Start / Stop an agent (`POST /agents/{key}/start`, `POST /agents/{key}/stop`)
- [x] Creates / Edit an agent (`POST /agents`, `PATCH /agents/{key}`)
-- [x] Archive an agent (`DELETE /agents/{key}` or `POST /agents/{key}/archive`)
+- [x] Archive one agent (`DELETE /agents/{key}` or `POST /agents/{key}/archive`) or bulk archive idle agents (`POST /agents/archive`)
- [x] Project list/detail endpoints and mobile project health/detail screens
- [x] Guarded deploy/maintenance/live preflight and start endpoints
-- [x] Remote setup/readiness endpoint and Setup screen
-- [x] Client-side Remote UI search across agents and projects
+- [x] Remote setup/readiness endpoint and Settings screen
+- [x] Client-side Remote UI filtering across agents and projects
- [x] Remote UI skill discovery for chat insertion
- [x] Browser push subscription/test-notification foundation, with HTTPS MagicDNS support and HTTP MagicDNS warnings ([WEB_PUSH_PLAN.md](./WEB_PUSH_PLAN.md))
- [x] Automatic browser push notifications when agents require response or finish
diff --git a/docs/REMOTE_SERVER.md b/docs/REMOTE_SERVER.md
index 065ab08..e108d7f 100644
--- a/docs/REMOTE_SERVER.md
+++ b/docs/REMOTE_SERVER.md
@@ -125,7 +125,7 @@ The UI is plain server-served HTML/CSS/JavaScript, with no frontend build step o
Home-screen launches are treated as normal browser sessions, but mobile browsers can be more aggressive about reusing an old app shell. The root UI references `/ui.css` and `/ui.js` with a content digest query string, and `POST /server/restart` is the explicit cache-reset path: the restart response sends cache-reset headers, the browser clears Cache Storage when available, and the UI reloads itself with a restart query string after the replacement server is healthy.
-The top-level mobile tabs are `Now`, `Agents`, `Search`, `Projects`, and `Setup`. Detail routes use hash navigation such as `#agent/{key}`, `#project/{key}`, and `#project/{key}/action/{action}`. The footer nav is fixed on top-level routes, hides while scrolling down, shows again while scrolling up, and is hidden on detail subpages.
+The top-level mobile tabs are `Now`, `Agents`, and `Settings`. Agents is the canonical project-and-agent workspace: it filters agents and project metadata, keeps zero-agent projects reachable for first-agent creation, and links to project detail routes. Legacy `#search`, `#projects`, and `#setup` hashes are redirected to the closest surviving tab. Detail routes use hash navigation such as `#agent/{key}`, `#project/{key}`, and `#project/{key}/action/{action}`. The footer nav is fixed on top-level routes, hides while scrolling down, shows again while scrolling up, and is hidden on detail subpages.
Browser push notification work is tracked in [WEB_PUSH_PLAN.md](./WEB_PUSH_PLAN.md). Push can use a Tailscale MagicDNS domain when it is served over HTTPS, preferably with Tailscale Serve or Tailscale Funnel. Plain HTTP MagicDNS URLs show a soft warning, but the UI still lets the user try enabling notifications when the browser exposes the required push APIs.
@@ -161,7 +161,7 @@ http://100.x.y.z:7373/
Authentication is optional for localhost. If `TYCHO_REMOTE_TOKEN` is unset or blank, requests are accepted without auth.
-When `tycho serve` binds to a non-loopback host without a token, startup logs print a warning. The Setup screen also marks public Remote UI URLs as `token recommended`.
+When `tycho serve` binds to a non-loopback host without a token, startup logs print a warning. The Settings screen also marks public Remote UI URLs as `token recommended`.
Set `TYCHO_REMOTE_TOKEN` before using a Tailscale MagicDNS URL or another non-local interface:
@@ -255,6 +255,7 @@ Conversation entries are projected from `AgentChatLog#chat_blocks` when availabl
| `GET` | `/agents/{key}` | Read one managed agent. |
| `PATCH` / `PUT` | `/agents/{key}` | Edit one idle managed agent. |
| `DELETE` | `/agents/{key}` | Archive one idle managed agent. |
+| `POST` | `/agents/archive` | Archive multiple idle managed agents from a `keys` array, returning archived, skipped, and failed keys. |
| `GET` | `/agents/{key}/conversation` | Read the rendered conversation blocks for one agent. |
| `PUT` | `/agents/{key}/reading` | Mark one agent as read after the user opens its conversation. |
| `POST` | `/agents/{key}/messages` | Append a user prompt to one agent. |
@@ -278,7 +279,7 @@ Conversation entries are projected from `AgentChatLog#chat_blocks` when availabl
| `GET` | `/attachments/{id}` | Read normalized attachment metadata and inline preview content when available. |
| `GET` | `/attachments/{id}/blob` | Stream the attachment file bytes for image and binary previews. |
| `GET` | `/setup` | Read Remote UI readiness, auth, Tailscale, config, log, and refresh metadata. |
-| `GET` | `/search` | Return agent and project payloads for client-side search. |
+| `GET` | `/search` | Return agent and project payloads for compatibility with older client-side search flows. |
| `GET` | `/`, `/ui`, `/ui.css`, `/ui.js` | Serve the Remote UI. `/ui` remains a compatibility alias. |
| `GET` | `/favicon.svg`, `/favicon.ico` | Serve the Remote UI favicon. |
@@ -545,6 +546,37 @@ Response:
}
```
+### `POST /agents/archive`
+
+Archives multiple idle agents from a `keys` array. Running agents are skipped and missing keys are reported without blocking idle agents in the same request.
+
+```bash
+curl -X POST http://127.0.0.1:7373/agents/archive \
+ -H "Content-Type: application/json" \
+ -d '{"keys":["web-charlie-agent-8","web-delta-agent-3"]}'
+```
+
+Response:
+
+```json
+{
+ "archived": [
+ {
+ "agent_key": "web-charlie-agent-8",
+ "archive_path": "/Users/example/.tycho/logs/agents/archive/20260508-001431-web-charlie-agent-8"
+ }
+ ],
+ "skipped": [
+ {
+ "agent_key": "web-delta-agent-3",
+ "reason": "running"
+ }
+ ],
+ "failed": [],
+ "archive_count": 1
+}
+```
+
### `POST /agents/{key}/clone`
Creates a fresh managed agent from an existing one with a new key, empty logs, no runs, and no native session id. Form fields such as `name`, `template_key`, `agent`, `workspace`, `prompt`, and `sandbox_mode` may be supplied to edit the clone before it is saved. Set `archive_source: true` to archive the source agent after the clone is created.
@@ -571,7 +603,7 @@ Response:
### `GET /push/config`
-Returns browser push readiness for the Remote UI Setup screen. The public VAPID key is safe for the browser; the private key remains server-side.
+Returns browser push readiness for the Remote UI Settings screen. The public VAPID key is safe for the browser; the private key remains server-side.
```json
{
diff --git a/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md b/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md
index 2f62a10..fa5186b 100644
--- a/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md
+++ b/docs/REMOTE_UI_IMPLEMENTATION_HANDOFF.md
@@ -23,19 +23,18 @@ Current implementation files:
- JavaScript: `lib/hq/remote_ui/assets/app.js`
- Static asset helper: `lib/hq/remote_ui.rb`
-The old phase checklist has been retired because the shell, top-level screens, main detail routes, project payloads, setup/readiness payloads, skill discovery, client-side search, guarded project actions, audit fixes, and mobile nav polish are now implemented.
+The old phase checklist has been retired because the shell, simplified top-level screens, main detail routes, project payloads, setup/readiness payloads, skill discovery, client-side filtering, guarded project actions, audit fixes, and mobile nav polish are now implemented.
## Implemented
-- Five top-level destinations: `Now`, `Agents`, `Search`, `Projects`, `Setup`.
+- Three top-level destinations: `Now`, `Agents`, `Settings`.
- Fixed bottom nav on top-level screens; it hides on downward scroll and reappears on upward scroll, focus, route changes, or near page top.
- Bottom nav is hidden on subpages with a back button.
- Deep links show loading states instead of transient not-found screens while initial data loads.
- Now screen shows attention count, paused/blocked agents, running agents, and search affordance.
-- Agents screen filters and groups managed agents by project.
-- Search screen searches agents and projects client-side, prioritizing unread agents on empty query.
-- Projects screen lists health, latency, group, and maintenance/action state.
-- Setup screen shows URL, Tailscale/MagicDNS state, auth state, harness readiness, schema/config readiness, logs/storage, refresh intervals, and safety defaults.
+- Agents screen filters and groups managed agents by project, matches project metadata, keeps zero-agent projects reachable, and supports bulk archiving idle agents.
+- Project detail routes remain reachable from Agents project headers.
+- Settings screen shows URL, Tailscale/MagicDNS state, auth state, harness readiness, schema/config readiness, logs/storage, refresh intervals, and safety defaults.
- Agent detail supports conversation viewing, current activity, run metadata, skill insertion, prompt submission, start run, and stop confirmation.
- Project detail shows health, revision, deploy details, versions/templates, recent agent summary, and guarded project actions.
- Guarded deploy/maintenance/live action screens show consequences and preflight checks before starting a detached Kamal action.
@@ -84,7 +83,7 @@ Manual smoke:
- Start `bin/tycho serve`.
- Open `/` at mobile width.
-- Check `Now`, `Agents`, `Search`, `Projects`, and `Setup`.
+- Check `Now`, `Agents`, and `Settings`.
- Confirm the footer nav hides while scrolling down and shows while scrolling up.
- Deep-link to an agent and project detail route.
- Open a guarded project action preflight.
diff --git a/lib/hq/remote_server.rb b/lib/hq/remote_server.rb
index ac9b25a..7d83191 100644
--- a/lib/hq/remote_server.rb
+++ b/lib/hq/remote_server.rb
@@ -188,6 +188,7 @@ def route(service, method, path, body, request = nil)
return accepted(service.start_schedule_daemon(body)) if method == "POST" && parts == ["schedules", "daemon", "start"]
return accepted(service.stop_schedule_daemon) if method == "POST" && parts == ["schedules", "daemon", "stop"]
return accepted(service.restart_schedule_daemon(body)) if method == "POST" && parts == ["schedules", "daemon", "restart"]
+ return ok(service.archive_agents(body)) if method == "POST" && parts == ["agents", "archive"]
return ok(projects: service.projects) if method == "GET" && parts == ["projects"]
return ok(hidden: service.hidden_settings) if method == "GET" && parts == ["settings", "hidden"]
return ok(hidden: service.update_hidden_setting(body)) if %w[PATCH PUT].include?(method) && parts == ["settings", "hidden"]
@@ -1001,6 +1002,45 @@ def archive_agent(key)
}
end
+ def archive_agents(attrs)
+ payload = attrs || {}
+ keys = Array(payload["keys"]).map { |key| key.to_s.strip }.reject(&:empty?).uniq
+ raise Error.new("Missing agent keys") if keys.empty?
+
+ current = load_all_agents
+ agents_by_key = current.to_h { |agent| [agent.key, agent] }
+ archived = []
+ skipped = []
+ failed = []
+
+ keys.each do |key|
+ target = agents_by_key[key]
+ unless target
+ failed << { agent_key: key, error: "Agent not found" }
+ next
+ end
+
+ if target.running?
+ skipped << { agent_key: key, reason: "running" }
+ next
+ end
+
+ archived << { agent_key: target.key, archive_path: target.archive_logs! }
+ rescue StandardError => e
+ failed << { agent_key: key, error: e.message }
+ end
+
+ archived_keys = archived.map { |item| item.fetch(:agent_key) }
+ save_agents(current.reject { |agent| archived_keys.include?(agent.key) }) if archived_keys.any?
+
+ {
+ archived: archived,
+ skipped: skipped,
+ failed: failed,
+ archive_count: archived.length
+ }
+ end
+
private
def visible_projects
diff --git a/lib/hq/remote_ui/assets/app.css b/lib/hq/remote_ui/assets/app.css
index 9ad69a1..d477b82 100644
--- a/lib/hq/remote_ui/assets/app.css
+++ b/lib/hq/remote_ui/assets/app.css
@@ -16,6 +16,12 @@
--yellow: #f1fa8c;
--accent-contrast: #282a36;
--shadow: 0 14px 32px rgba(0, 0, 0, 0.32);
+ --safe-area-top: env(safe-area-inset-top, 0px);
+ --ipad-header-control-space: 0px;
+}
+
+html.ipad-standalone {
+ --ipad-header-control-space: 60px;
}
* {
@@ -120,13 +126,15 @@ h3 {
width: 100%;
max-width: 820px;
min-height: 100vh;
+ min-height: 100dvh;
margin: 0 auto;
+ padding-top: var(--safe-area-top);
}
.app-header {
--header-offset-y: 0;
position: sticky;
- top: 0;
+ top: var(--safe-area-top);
z-index: 7;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 95%, transparent);
@@ -150,7 +158,7 @@ h3 {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
- padding: 10px 12px;
+ padding: 10px 12px 10px calc(12px + var(--ipad-header-control-space));
}
.header-row > #back-button {
@@ -306,15 +314,28 @@ h3 {
font-size: 13px;
}
-.subtitle-refresh {
- display: inline-flex;
+.subtitle-status {
+ display: inline-grid;
+ grid-template-columns: 14px minmax(0, 1fr);
align-items: center;
gap: 6px;
+ max-width: 100%;
}
-.subtitle-refresh .ui-icon {
+.subtitle-status .ui-icon {
width: 14px;
height: 14px;
+ color: var(--muted);
+}
+
+.subtitle-status-text {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.subtitle-refresh .ui-icon {
color: var(--accent);
animation: poll-refresh-hourglass 1s infinite;
}
@@ -446,6 +467,15 @@ h3 {
flex-wrap: wrap;
}
+.top-actions .search-box {
+ flex: 1 1 auto;
+}
+
+.top-actions > button {
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
.compact-actions button {
padding: 7px 9px;
}
@@ -566,7 +596,7 @@ select {
left: 50%;
z-index: 4;
display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
width: 100%;
max-width: 820px;
@@ -948,6 +978,8 @@ select {
}
.search-box input {
+ flex: 1 1 auto;
+ min-width: 0;
border: 0;
background: transparent;
padding: 2px 0;
@@ -1048,6 +1080,57 @@ select {
flex: 1 1 140px;
}
+.bulk-action-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 10px;
+}
+
+.bulk-action-bar > div:first-child {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+}
+
+.bulk-action-bar strong,
+.bulk-action-bar span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.bulk-action-bar span {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.selectable-agent-row {
+ cursor: pointer;
+}
+
+.selectable-agent-row.selected {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.selectable-agent-row.disabled {
+ cursor: not-allowed;
+ opacity: 0.66;
+}
+
+.agent-select-box {
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ accent-color: var(--accent);
+}
+
.unread-dot {
width: 9px;
height: 9px;
@@ -1104,7 +1187,9 @@ button.restart-server-button {
.message {
display: grid;
gap: 5px;
+ min-width: 0;
width: calc(100% - 28px);
+ max-width: 100%;
margin-right: 28px;
padding: 10px 12px;
}
@@ -1214,10 +1299,58 @@ button.restart-server-button {
}
.message-content {
+ min-width: 0;
+ max-width: 100%;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
+.message-content.markdown-message-content {
+ text-align: left;
+ white-space: normal;
+}
+
+.message-markdown-viewer {
+ min-width: 0;
+ max-width: 100%;
+ font-size: 14px;
+ line-height: 1.55;
+}
+
+.message-markdown-viewer h1,
+.message-markdown-viewer h2 {
+ margin: 0 0 9px;
+ font-size: 16px;
+ line-height: 1.25;
+}
+
+.message-markdown-viewer h3,
+.message-markdown-viewer h4,
+.message-markdown-viewer h5,
+.message-markdown-viewer h6 {
+ margin: 14px 0 7px;
+ font-size: 14px;
+}
+
+.message-markdown-viewer p,
+.message-markdown-viewer ul,
+.message-markdown-viewer ol,
+.message-markdown-viewer blockquote,
+.message-markdown-viewer pre,
+.message-markdown-viewer table,
+.message-markdown-viewer hr {
+ margin-bottom: 10px;
+}
+
+.message-markdown-fallback,
+.summary-markdown-fallback {
+ margin: 0;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ color: var(--text);
+ font: inherit;
+}
+
.parsed-json-message {
white-space: normal;
}
@@ -1674,6 +1807,7 @@ button.restart-server-button {
font-size: 14px;
line-height: 1.65;
overflow-wrap: break-word;
+ white-space: normal;
}
.markdown-viewer > :first-child {
@@ -1757,15 +1891,19 @@ button.restart-server-button {
}
.markdown-viewer code {
+ max-width: 100%;
border-radius: 4px;
background: color-mix(in srgb, var(--panel-soft) 72%, var(--bg));
padding: 2px 5px;
color: var(--yellow);
font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.92em;
+ overflow-wrap: anywhere;
+ word-break: break-word;
}
.markdown-viewer pre {
+ min-width: 0;
max-width: 100%;
overflow-x: auto;
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--panel-soft));
@@ -1778,15 +1916,19 @@ button.restart-server-button {
}
.markdown-viewer pre code {
+ max-width: none;
border-radius: 0;
background: transparent;
padding: 0;
color: inherit;
font-size: inherit;
+ overflow-wrap: normal;
+ word-break: normal;
}
.markdown-viewer table {
display: block;
+ min-width: 0;
width: 100%;
max-width: 100%;
overflow-x: auto;
@@ -1933,7 +2075,8 @@ button.restart-server-button {
background: var(--panel-soft);
}
-.agent-summary-panel p {
+.agent-summary-panel > p,
+.agent-summary-panel .summary-markdown-fallback {
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
@@ -1942,6 +2085,34 @@ button.restart-server-button {
white-space: pre-wrap;
}
+.summary-markdown-viewer {
+ min-width: 0;
+ max-width: 100%;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.summary-markdown-viewer h1,
+.summary-markdown-viewer h2,
+.summary-markdown-viewer h3,
+.summary-markdown-viewer h4,
+.summary-markdown-viewer h5,
+.summary-markdown-viewer h6 {
+ margin: 0 0 7px;
+ font-size: 13px;
+ line-height: 1.25;
+}
+
+.summary-markdown-viewer p,
+.summary-markdown-viewer ul,
+.summary-markdown-viewer ol,
+.summary-markdown-viewer blockquote,
+.summary-markdown-viewer pre,
+.summary-markdown-viewer table,
+.summary-markdown-viewer hr {
+ margin-bottom: 8px;
+}
+
.go-recent-fab {
white-space: nowrap;
}
diff --git a/lib/hq/remote_ui/assets/app.js b/lib/hq/remote_ui/assets/app.js
index ec2369d..bb5b2fb 100644
--- a/lib/hq/remote_ui/assets/app.js
+++ b/lib/hq/remote_ui/assets/app.js
@@ -1,4 +1,4 @@
-const TOP_TABS = ["now", "agents", "search", "projects", "setup"];
+const TOP_TABS = ["now", "agents", "settings"];
const PROJECT_ACTIONS = ["deploy", "maintenance", "live"];
const BUILTIN_AGENT_HARNESSES = ["codex", "claude"];
const DEFAULT_REFRESH_INTERVALS = {
@@ -300,15 +300,16 @@ const state = {
skills: {},
preflights: {},
filters: {
- search: "",
agents: "",
- projects: "",
},
+ bulkArchiveMode: false,
+ bulkArchiveSelection: new Set(),
failureCount: 0,
timer: null,
lastUpdatedAt: null,
connection: "Connecting",
headerSubtitle: "Connecting",
+ headerSubtitleIcon: "folder",
refreshing: false,
loadingConversation: false,
lastScrollY: 0,
@@ -345,6 +346,16 @@ const els = {
nav: document.getElementById("bottom-nav"),
};
+syncPlatformClasses();
+
+function syncPlatformClasses() {
+ const standalone = window.navigator.standalone === true ||
+ window.matchMedia?.("(display-mode: standalone)")?.matches;
+ const platform = window.navigator.platform || "";
+ const iPadLike = platform === "iPad" || (platform === "MacIntel" && window.navigator.maxTouchPoints > 1);
+ document.documentElement.classList.toggle("ipad-standalone", Boolean(standalone && iPadLike));
+}
+
function token() {
return localStorage.getItem("hq.remote.token") || "";
}
@@ -448,7 +459,9 @@ function parseRoute() {
return { type: "guard", key: parts[1], action: parts[3] };
}
if (parts[0] === "project" && parts[1]) return { type: "project", key: parts[1] };
- if (parts[0] === "setup" && parts[1] === "hidden") return { type: "hiddenSettings" };
+ if ((parts[0] === "settings" || parts[0] === "setup") && parts[1] === "hidden") return { type: "hiddenSettings" };
+ if (parts[0] === "setup") return { type: "tab", tab: "settings" };
+ if (parts[0] === "search" || parts[0] === "projects") return { type: "tab", tab: "agents" };
if (TOP_TABS.includes(parts[0])) return { type: "tab", tab: parts[0] };
return { type: "tab", tab: "now" };
}
@@ -473,7 +486,7 @@ function routeHash(route) {
if (route.type === "guard") {
return `#project/${encodeURIComponent(route.key)}/action/${encodeURIComponent(route.action)}`;
}
- if (route.type === "hiddenSettings") return "#setup/hidden";
+ if (route.type === "hiddenSettings") return "#settings/hidden";
return `#${route.tab}`;
}
@@ -507,10 +520,10 @@ function scrollPageToTop() {
function currentTopTab(route) {
if (route.type === "tab") return route.tab;
- if (route.type === "project" || route.type === "guard") return "projects";
- if (route.type === "agentForm") return route.mode === "create" ? "projects" : "agents";
+ if (route.type === "project" || route.type === "guard") return "agents";
+ if (route.type === "agentForm") return "agents";
if (route.type === "agent" || route.type === "agentAttachment" || route.type === "agentArchive" || route.type === "attachment") return "agents";
- if (route.type === "hiddenSettings") return "setup";
+ if (route.type === "hiddenSettings") return "settings";
return "now";
}
@@ -564,6 +577,7 @@ async function refresh(options = {}) {
state.openSummaryAfterAutoScroll = true;
}
state.agents = nextAgents;
+ syncBulkArchiveSelection();
state.projects = projectsData.projects || [];
state.setup = setupData.setup || null;
state.schedules = schedulesData.schedules || [];
@@ -790,13 +804,7 @@ function render() {
} else if (route.tab === "agents") {
state.openSummaryAfterAutoScroll = false;
renderAgents();
- } else if (route.tab === "search") {
- state.openSummaryAfterAutoScroll = false;
- renderSearch();
- } else if (route.tab === "projects") {
- state.openSummaryAfterAutoScroll = false;
- renderProjects();
- } else if (route.tab === "setup") {
+ } else if (route.tab === "settings") {
state.openSummaryAfterAutoScroll = false;
renderSetup();
} else {
@@ -835,7 +843,7 @@ function routeStateKey(route) {
if (route.type === "agentArchive") return `agent-archive:${route.key}`;
if (route.type === "project") return `project:${route.key}`;
if (route.type === "guard") return `guard:${route.key}:${route.action}`;
- if (route.type === "hiddenSettings") return "setup:hidden";
+ if (route.type === "hiddenSettings") return "settings:hidden";
return `tab:${route.tab}`;
}
@@ -1087,7 +1095,7 @@ function markdownHeadingSlug(text) {
function handleMarkdownAnchorClick(anchor, event) {
const route = parseRoute();
- if (route.type !== "attachment" && route.type !== "agentAttachment") return false;
+ if (!["agent", "agentAttachment", "attachment"].includes(route.type)) return false;
const href = String(anchor.getAttribute("href") || "");
if (!href.startsWith("#") || href === "#") return false;
@@ -1314,60 +1322,46 @@ function renderScheduleDaemonActions(daemon, schedules) {
}
function renderAgents() {
- const query = state.filters.agents.toLowerCase();
- const filtered = state.agents.filter((agent) => agentMatches(agent, query));
- const groups = sortedAgentGroups(filtered);
+ const query = state.filters.agents.trim().toLowerCase();
+ const groups = agentProjectGroups(query);
const unread = state.agents.filter((agent) => agent.unread).length;
- setHeader("Agents", `${state.agents.length} managed / ${unread} unread`, "A");
+ setHeader("Agents", `${state.agents.length} managed / ${state.projects.length} projects / ${unread} unread`, "A");
replaceView(`
${iconSvg("search")}
-
+
- Projects
-
- ${filtered.length ? groups.map((group) => renderAgentGroup(group.projectKey, group.agents)).join("") : emptyState("No agents", "Open a project detail to add the first managed agent.")}
- `);
-}
-
-function renderSearch() {
- const query = state.filters.search.trim().toLowerCase();
- const results = searchResults(query);
- setHeader("Search HQ", query ? `${results.length} results` : "Unread agents first", "search");
-
- replaceView(`
-
- ${iconSvg("search")}
-
-
- ${query ? "Results" : "Suggested"} ${query ? "direct navigation" : "unread first"}
-
- ${results.length ? results.map(renderSearchResult).join("") : emptyRow("No matches", "Try an agent name, project key, status, branch, or summary.")}
+ ${iconSvg(state.bulkArchiveMode ? "x" : "archive")}${state.bulkArchiveMode ? "Cancel" : "Select"}
+ ${renderBulkArchiveBar()}
+ ${groups.length ? groups.map((group) => renderAgentGroup(group.projectKey, group.agents)).join("") : renderAgentsEmpty(query)}
`);
- focusSearchInput();
+ focusFilterInput("agents", "agent-filter");
}
-function renderProjects() {
- const query = state.filters.projects.toLowerCase();
- const filtered = sortedProjects(state.projects).filter((project) => projectMatches(project, query));
- const maintenanceCount = state.projects.filter((project) => project.maintenance).length;
- setHeader("Projects", `${state.projects.length} active / ${maintenanceCount} maintenance`, "folders");
+function renderBulkArchiveBar() {
+ if (!state.bulkArchiveMode) return "";
- replaceView(`
-
- ${iconSvg("search")}
-
-
- ${filtered.length ? `${filtered.map(renderProjectRow).join("")}
` : emptyState("No projects configured", "Project setup still happens in the TUI because it needs local filesystem access.")}
- `);
- focusFilterInput("projects", "project-filter");
+ const selected = selectedBulkArchiveKeys();
+ return `
+
+
+ ${selected.length} selected
+ ${archiveableAgents().length} archiveable agents
+
+
+ Clear
+ ${iconSvg("archive")}Archive selected
+
+
+ `;
}
-function focusSearchInput() {
- focusFilterInput("search", "search-input");
+function renderAgentsEmpty(query) {
+ if (query) return emptyState("No matches", "Try an agent name, project name, status, branch, or summary.");
+ return emptyState("No projects configured", "Project setup still happens in the TUI because it needs local filesystem access.");
}
function focusFilterInput(tab, inputId) {
@@ -1387,9 +1381,9 @@ function focusFilterInput(tab, inputId) {
function renderSetup() {
const setup = state.setup;
- setHeader("Setup", connectionText(), "S");
+ setHeader("Settings", connectionText(), "S");
if (!setup) {
- replaceView(emptyState("Setup unavailable", "Refresh to load Remote UI readiness."));
+ replaceView(emptyState("Settings unavailable", "Refresh to load Remote UI readiness."));
return;
}
@@ -1638,7 +1632,7 @@ function renderAgent(key, options = {}) {
${attachmentId ? renderAgentAttachmentView(agent, attachmentId) : renderAgentConversationView(agent, blocks)}
- ${escapeHtml(agentSummaryText(agent))}
+ ${renderAgentSummaryContent(agent)}
${agent.latest_inquiry ? renderInquiryForm(agent, { attachmentMode: Boolean(attachmentId) }) : renderAgentComposer(agent, skills, { attachmentMode: Boolean(attachmentId) })}
@@ -1646,6 +1640,14 @@ function renderAgent(key, options = {}) {
if (!attachmentId) scheduleAgentReading(agent);
}
+function renderAgentSummaryContent(agent) {
+ return renderMarkdown(agentSummaryText(agent), {
+ viewerClassName: "markdown-viewer summary-markdown-viewer",
+ fallbackClassName: "summary-markdown-fallback",
+ emptyText: "No run summary yet.",
+ });
+}
+
function renderAgentConversationView(agent, blocks) {
return `
@@ -2096,7 +2098,7 @@ function renderProject(key) {
}
setHeader("Project not found", "It may have been removed from config.", "folder");
- replaceView(emptyState("Project not found", "Return to the Projects tab and choose an active project."));
+ replaceView(emptyState("Project not found", "Return to the Agents tab and choose an active project."));
return;
}
@@ -2170,7 +2172,7 @@ function renderGuardedAction(projectKey, action) {
}
if (!project) {
- replaceView(emptyState("Project not found", "Return to the Projects tab and choose an active project."));
+ replaceView(emptyState("Project not found", "Return to the Agents tab and choose an active project."));
return;
}
@@ -2260,21 +2262,28 @@ function renderAgentSection(title, subtitle, agents, emptyText) {
function renderAgentGroup(projectKey, agents) {
const project = findProject(projectKey);
const projectName = project?.name || projectKey;
- const projectHref = routeHash({ type: "project", key: projectKey });
+ const projectMeta = project ? `${agents.length} agents / ${project.health_status || project.status || "configured"}` : `${agents.length} agents`;
return `
-
${escapeHtml(projectName)}
-
${agents.length} agents
+ ${renderAgentGroupProjectTitle(projectKey, projectName, !!project)}
+
${escapeHtml(projectMeta)}
-
${iconSvg("plus")}Agent
+ ${project ? `
${iconSvg("plus")}Agent ` : ""}
- ${agents.map(renderAgentRow).join("")}
+ ${agents.length ? agents.map(renderAgentRow).join("") : renderProjectAgentEmpty()}
`;
}
+function renderAgentGroupProjectTitle(projectKey, projectName, linked) {
+ if (!linked) return `
${escapeHtml(projectName)} `;
+
+ const projectHref = routeHash({ type: "project", key: projectKey });
+ return `
${escapeHtml(projectName)} `;
+}
+
function renderAgentCard(agent) {
return `
@@ -2290,6 +2299,8 @@ function renderAgentCard(agent) {
}
function renderAgentRow(agent) {
+ if (state.bulkArchiveMode) return renderSelectableAgentRow(agent);
+
return `
${iconSvg("robot")}
@@ -2302,44 +2313,18 @@ function renderAgentRow(agent) {
`;
}
-function renderProjectRow(project) {
- return `
-
- ${iconSvg("folder")}
-
- ${escapeHtml(project.name || project.key)}
- ${escapeHtml(`${project.status || "configured"} / ${latencyText(project)} / ${project.group || "ungrouped"}`)}
-
- ${escapeHtml(project.maintenance ? "Maintenance" : project.health_status || "Open")}
-
- `;
-}
-
-function renderSearchResult(result) {
- if (result.type === "agent") {
- const agent = result.item;
- return `
-
- ${iconSvg("robot")}
-
- ${escapeHtml(agent.name || agent.key)}
- ${agentSearchSubtextHtml(agent)}
-
- ${agent.unread ? `Unread ` : `Agent `}
-
- `;
- }
-
- const project = result.item;
+function renderSelectableAgentRow(agent) {
+ const selected = state.bulkArchiveSelection.has(agent.key);
+ const disabled = !agentArchiveable(agent);
return `
-
- ${iconSvg("folder")}
+
+
- ${escapeHtml(project.name || project.key)}
- ${escapeHtml(projectSearchSubtext(project))}
+ ${escapeHtml(agent.name || agent.key)}
+ ${agentListSubtextHtml(agent)}
- Project
-
+ ${escapeHtml(disabled ? "Running" : (agent.last_result || "archiveable"))}
+
`;
}
@@ -2446,12 +2431,29 @@ function inquiryResponseBlock(block) {
function renderMessageContent(block) {
const content = String(block.content || "");
+ if (markdownMessageBlock(block)) {
+ return `
+
+ ${renderMarkdown(content, {
+ viewerClassName: "markdown-viewer message-markdown-viewer",
+ fallbackClassName: "message-markdown-fallback",
+ emptyText: "",
+ })}
+
+ `;
+ }
+
const formatted = block.role === "user" ? formatJsonObjectMessage(content) : "";
if (formatted) return `${formatted}
`;
return `${escapeHtml(content)}
`;
}
+function markdownMessageBlock(block) {
+ if (block?.kind === "run_summary") return true;
+ return block?.kind === "message" && block.role === "assistant";
+}
+
function formatJsonObjectMessage(content) {
let parsed;
try {
@@ -2988,12 +2990,12 @@ function openAttachmentLinkOnce(id, href) {
window.setTimeout(() => window.open(href, "_blank", "noopener"), 0);
}
-function renderMarkdown(text) {
+function renderMarkdown(text, options = {}) {
const source = String(text || "");
- if (markdownParserReady()) return renderParsedMarkdown(source);
+ if (markdownParserReady()) return renderParsedMarkdown(source, options);
ensureMarkdownParserLoaded();
- return renderPlainTextMarkdown(source);
+ return renderPlainTextMarkdown(source, options);
}
function markdownParserReady() {
@@ -3010,7 +3012,7 @@ function ensureMarkdownParserLoaded() {
loadExternalScript(MARKDOWN_SCRIPT_URLS.marked),
]).then(() => {
if (!markdownParserReady()) throw new Error("Markdown parser globals unavailable");
- renderMarkdownAttachmentRoute();
+ renderMarkdownRoute();
return true;
}).catch((error) => {
markdownParser.failed = true;
@@ -3046,8 +3048,14 @@ function loadExternalScript(src) {
});
}
-function renderMarkdownAttachmentRoute() {
+function renderMarkdownRoute() {
const route = parseRoute();
+ if (route.type === "agent" || route.type === "agentAttachment") {
+ state.renderedViewHtml = "";
+ render();
+ return;
+ }
+
if (route.type !== "attachment" && route.type !== "agentAttachment") return;
const attachmentId = route.type === "agentAttachment" ? route.attachmentId : route.id;
@@ -3060,18 +3068,24 @@ function renderMarkdownAttachmentRoute() {
render();
}
-function renderParsedMarkdown(text) {
+function renderParsedMarkdown(text, options = {}) {
const unsafe = window.marked.parse(text, { gfm: true });
const safe = window.DOMPurify.sanitize(unsafe, {
USE_PROFILES: { html: true },
FORBID_TAGS: ["embed", "iframe", "img", "object", "script", "style"],
FORBID_ATTR: ["style", "srcset"],
});
- return `${safe}
`;
+ return `${safe}
`;
}
-function renderPlainTextMarkdown(text) {
- return `${escapeHtml(text || "No content")} `;
+function renderPlainTextMarkdown(text, options = {}) {
+ const className = String(options.fallbackClassName || "attachment-text-viewer").trim() || "attachment-text-viewer";
+ const emptyText = Object.prototype.hasOwnProperty.call(options, "emptyText") ? options.emptyText : "No content";
+ return `${escapeHtml(text || emptyText)} `;
+}
+
+function markdownViewerClassName(options = {}) {
+ return String(options.viewerClassName || "markdown-viewer").trim() || "markdown-viewer";
}
function messageIcon(block) {
@@ -3105,20 +3119,6 @@ function kv(label, value) {
return `${escapeHtml(label)} ${escapeHtml(display)}
`;
}
-function searchResults(query) {
- const agentResults = state.agents
- .filter((agent) => !query || agentMatches(agent, query))
- .map((agent) => ({ type: "agent", item: agent }));
- const projectResults = state.projects
- .filter((project) => !query || projectMatches(project, query))
- .map((project) => ({ type: "project", item: project }));
-
- if (query) return [...agentResults, ...projectResults].slice(0, 40);
-
- return [...agentResults.sort((a, b) => agentPriority(b.item) - agentPriority(a.item)), ...projectResults]
- .slice(0, 40);
-}
-
function unreadAgents() {
return state.agents
.filter((agent) => agent.unread)
@@ -3139,19 +3139,30 @@ function agentActivityTimestamp(agent) {
return Date.parse(agent.updated_at || agent.finished_at || agent.started_at || agent.created_at || "") || 0;
}
-function sortedProjects(projects) {
- return [...projects].sort(compareProjectsByName);
+function agentProjectGroups(query) {
+ const agentsByProject = groupBy(state.agents, (agent) => agent.project_key || "unassigned");
+ const projectKeys = new Set([
+ ...state.projects.map((project) => project.key),
+ ...Object.keys(agentsByProject),
+ ]);
+
+ return [...projectKeys]
+ .sort(compareAgentProjectKeys)
+ .map((projectKey) => agentProjectGroup(projectKey, agentsByProject[projectKey] || [], query))
+ .filter(Boolean);
}
-function sortedAgentGroups(agents) {
- const groups = groupBy(agents, (agent) => agent.project_key || "unassigned");
+function agentProjectGroup(projectKey, agents, query) {
+ const project = findProject(projectKey);
+ const projectMatch = project ? projectMatches(project, query) : String(projectKey || "").toLowerCase().includes(query);
+ const filteredAgents = agents
+ .filter((agent) => !query || projectMatch || agentMatches(agent, query))
+ .sort(compareAgentsByName);
- return Object.keys(groups)
- .sort(compareAgentProjectKeys)
- .map((projectKey) => ({
- projectKey,
- agents: [...groups[projectKey]].sort(compareAgentsByName),
- }));
+ if (query && !projectMatch && filteredAgents.length === 0) return null;
+ if (!query && !project && filteredAgents.length === 0) return null;
+
+ return { projectKey, agents: filteredAgents };
}
function agentsForProject(projectKey) {
@@ -3160,13 +3171,25 @@ function agentsForProject(projectKey) {
.sort(compareAgentsByName);
}
-function compareProjectsByName(a, b) {
- const name = String(a.name || a.key || "");
- const otherName = String(b.name || b.key || "");
- const byName = compareDisplayText(name, otherName);
- if (byName !== 0) return byName;
+function agentArchiveable(agent) {
+ return !!agent && !agent.running && agent.status !== "running";
+}
- return compareDisplayText(a.key, b.key);
+function archiveableAgents() {
+ return state.agents.filter(agentArchiveable);
+}
+
+function selectedBulkArchiveKeys() {
+ syncBulkArchiveSelection();
+ return [...state.bulkArchiveSelection];
+}
+
+function syncBulkArchiveSelection() {
+ const activeKeys = new Set(state.agents.map((agent) => agent.key));
+ state.bulkArchiveSelection.forEach((key) => {
+ const agent = state.agents.find((item) => item.key === key);
+ if (!activeKeys.has(key) || !agentArchiveable(agent)) state.bulkArchiveSelection.delete(key);
+ });
}
function compareAgentProjectKeys(a, b) {
@@ -3230,13 +3253,18 @@ function projectMatches(project, query) {
].some((value) => String(value || "").toLowerCase().includes(query));
}
-function setHeader(title, subtitle) {
+function setHeader(title, subtitle, subtitleIcon = "folder") {
els.title.textContent = title;
+ setHeaderSubtitleIcon(subtitleIcon);
setHeaderSubtitle(subtitle);
syncUnreadAlert();
syncDetailHeaderLayout();
}
+function setHeaderSubtitleIcon(icon) {
+ state.headerSubtitleIcon = headerSubtitleIconName(icon);
+}
+
function setHeaderSubtitle(text) {
state.headerSubtitle = String(text || "");
renderHeaderSubtitle();
@@ -3244,12 +3272,19 @@ function setHeaderSubtitle(text) {
function renderHeaderSubtitle() {
const value = state.headerSubtitle;
- if (state.refreshing) {
- els.subtitle.innerHTML = `${iconSvg("hourglass")}${escapeHtml(value)} `;
- return;
- }
+ const icon = state.refreshing ? iconSvg("hourglass") : iconSvg(state.headerSubtitleIcon);
+ const className = state.refreshing ? "subtitle-status subtitle-refresh" : "subtitle-status";
+ els.subtitle.innerHTML = `${icon} ${escapeHtml(value)} `;
+}
- els.subtitle.textContent = value;
+function headerSubtitleIconName(icon) {
+ const requested = String(icon || "").trim();
+ const aliases = {
+ A: "folder",
+ HQ: "activity",
+ };
+ const name = aliases[requested] || requested || "folder";
+ return ICONS[name] ? name : "folder";
}
function syncUnreadAlert() {
@@ -4015,21 +4050,6 @@ function agentListSubtextHtml(agent) {
return [relativeTimeHtml(agentUpdatedAt(agent)), escapeHtml(agentMeta(agent))].filter(Boolean).join(" / ");
}
-function agentSearchSubtextHtml(agent) {
- return [
- relativeTimeHtml(agentUpdatedAt(agent)),
- escapeHtml(agentMeta(agent)),
- escapeHtml(statusLabel(agent)),
- ].filter(Boolean).join(" / ");
-}
-
-function projectSearchSubtext(project) {
- return [
- project.status || "configured",
- project.branch || project.group || "ungrouped",
- ].filter(Boolean).join(" / ");
-}
-
function actionText(actionState) {
if (!actionState) return "No active or recent action";
return [actionState.label, actionState.status, timeShort(actionState.started_at)].filter(Boolean).join(" / ");
@@ -4328,8 +4348,8 @@ els.back.addEventListener("click", () => {
else if (route.type === "agentForm" && route.mode === "clone") navigate({ type: "agentArchive", key: route.key });
else if (route.type === "agentForm" && route.mode === "create") navigate({ type: "project", key: route.projectKey });
else if (route.type === "agentArchive") navigate({ type: "agent", key: route.key });
- else if (route.type === "project" || route.type === "guard") navigate({ type: "tab", tab: "projects" });
- else if (route.type === "hiddenSettings") navigate({ type: "tab", tab: "setup" });
+ else if (route.type === "project" || route.type === "guard") navigate({ type: "tab", tab: "agents" });
+ else if (route.type === "hiddenSettings") navigate({ type: "tab", tab: "settings" });
else navigate({ type: "tab", tab: "now" });
});
@@ -4413,6 +4433,22 @@ els.view.addEventListener("click", (event) => {
return;
}
+ if (event.target.closest("[data-toggle-bulk-archive]")) {
+ toggleBulkArchiveMode();
+ return;
+ }
+
+ if (event.target.closest("[data-clear-bulk-archive]")) {
+ state.bulkArchiveSelection.clear();
+ render();
+ return;
+ }
+
+ if (event.target.closest("[data-run-bulk-archive]")) {
+ archiveSelectedAgents();
+ return;
+ }
+
const tabButton = event.target.closest("[data-open-tab]");
if (tabButton) {
navigate({ type: "tab", tab: tabButton.dataset.openTab });
@@ -4616,8 +4652,10 @@ els.view.addEventListener("keydown", (event) => {
if (event.target?.id === "agent-filter") {
event.preventDefault();
const query = state.filters.agents.toLowerCase();
- const first = state.agents.find((agent) => agentMatches(agent, query));
- if (first) navigate({ type: "agent", key: first.key });
+ const firstGroup = agentProjectGroups(query)[0];
+ const firstAgent = firstGroup?.agents[0];
+ if (firstAgent) navigate({ type: "agent", key: firstAgent.key });
+ else if (firstGroup?.projectKey && findProject(firstGroup.projectKey)) navigate({ type: "project", key: firstGroup.projectKey });
}
});
@@ -4661,6 +4699,15 @@ els.view.addEventListener("change", (event) => {
syncViewControls();
}
+ const agentSelection = event.target.closest("[data-select-agent]");
+ if (agentSelection) {
+ const key = agentSelection.dataset.selectAgent;
+ if (agentSelection.checked) state.bulkArchiveSelection.add(key);
+ else state.bulkArchiveSelection.delete(key);
+ render();
+ return;
+ }
+
const attachmentInput = event.target.closest("[data-prompt-attachment-input]");
if (attachmentInput) {
handlePromptAttachmentFiles(attachmentInput.dataset.agentKey, attachmentInput.files);
@@ -4769,6 +4816,33 @@ function runAgentAction(agentAction) {
});
}
+function toggleBulkArchiveMode() {
+ state.bulkArchiveMode = !state.bulkArchiveMode;
+ if (!state.bulkArchiveMode) state.bulkArchiveSelection.clear();
+ render();
+}
+
+function archiveSelectedAgents() {
+ const keys = selectedBulkArchiveKeys();
+ if (keys.length === 0) return;
+
+ const noun = keys.length === 1 ? "agent" : "agents";
+ if (!window.confirm(`Archive ${keys.length} selected ${noun}? Running agents are not included.`)) return;
+
+ mutate(async () => {
+ const result = await apiPost("/agents/archive", { keys });
+ const archivedKeys = (result.archived || []).map((item) => item.agent_key).filter(Boolean);
+ archivedKeys.forEach(removeAgent);
+ state.bulkArchiveSelection.clear();
+ state.bulkArchiveMode = false;
+
+ const skipped = result.skipped?.length || 0;
+ const failed = result.failed?.length || 0;
+ const detail = [skipped ? `${skipped} skipped` : null, failed ? `${failed} failed` : null].filter(Boolean).join(" / ");
+ setConnection(detail ? `Archived ${archivedKeys.length} / ${detail}` : `Archived ${archivedKeys.length} ${noun}`);
+ });
+}
+
function runSchedulerAction(schedulerAction) {
const action = schedulerAction.dataset.schedulerAction;
const confirmation = schedulerAction.dataset.confirm;
diff --git a/lib/hq/remote_ui/templates/index.html.erb b/lib/hq/remote_ui/templates/index.html.erb
index 6ead003..9c6ce0b 100644
--- a/lib/hq/remote_ui/templates/index.html.erb
+++ b/lib/hq/remote_ui/templates/index.html.erb
@@ -2,7 +2,7 @@
-
+
@@ -94,32 +94,14 @@
Agents
-
-
-
-
-
-
-
- Search
-
-
-
-
-
-
-
-
- Projects
-
-
+
- Setup
+ Settings
diff --git a/test/remote_server_test.rb b/test/remote_server_test.rb
index 4311078..c636f8d 100644
--- a/test/remote_server_test.rb
+++ b/test/remote_server_test.rb
@@ -13,6 +13,7 @@ module RemoteServerTest
def run!
assert_remote_agent_lifecycle
+ assert_remote_agent_bulk_archive
assert_remote_agent_clone_archives_source_with_editable_name
assert_remote_agent_payload_has_revision
assert_remote_inquiry_payload_has_stable_id_and_guarded_answer
@@ -83,6 +84,79 @@ def assert_remote_agent_lifecycle
end
end
+ def assert_remote_agent_bulk_archive
+ running_pid = nil
+ with_remote_temp_store do |dir|
+ workspace = File.join(dir, "workspace")
+ FileUtils.mkdir_p(workspace)
+ registry = registry_for(dir, workspace)
+ service = HQ::RemoteService.new(registry: registry)
+ server = HQ::RemoteServer.new
+
+ archive_one = service.create_agent(
+ "project_key" => "web",
+ "template_key" => "custom",
+ "name" => "Archive One",
+ "prompt" => "Archive this.",
+ "agent" => "codex"
+ )
+ archive_two = service.create_agent(
+ "project_key" => "web",
+ "template_key" => "custom",
+ "name" => "Archive Two",
+ "prompt" => "Archive this too.",
+ "agent" => "codex"
+ )
+ running = service.create_agent(
+ "project_key" => "web",
+ "template_key" => "custom",
+ "name" => "Running Agent",
+ "prompt" => "Keep running.",
+ "agent" => "codex"
+ )
+ [archive_one, archive_two, running].each { |agent| File.write(agent[:log_path], "#{agent[:name]}\n") }
+
+ running_pid = Process.spawn(RbConfig.ruby, "-e", "sleep 30", pgroup: true, out: File::NULL, err: File::NULL)
+ stored = JSON.parse(File.read(HQ::AGENTS_FILE))
+ stored.each do |agent|
+ next unless agent["key"] == running[:key]
+
+ agent["pid"] = running_pid
+ agent["started_at"] = Time.now.iso8601
+ end
+ File.write(HQ::AGENTS_FILE, JSON.pretty_generate(stored))
+
+ response = server.send(
+ :route,
+ service,
+ "POST",
+ "/agents/archive",
+ { "keys" => [archive_one[:key], running[:key], "missing-agent", archive_two[:key], archive_one[:key]] },
+ nil
+ )
+
+ archived_keys = response.dig(:body, :archived).map { |item| item[:agent_key] }
+ assert(archived_keys == [archive_one[:key], archive_two[:key]], "expected bulk archive to archive unique idle agents")
+ assert(response.dig(:body, :skipped).map { |item| item[:agent_key] } == [running[:key]],
+ "expected bulk archive to skip running agents")
+ assert(response.dig(:body, :failed).map { |item| item[:agent_key] } == ["missing-agent"],
+ "expected bulk archive to report missing agents")
+ assert(service.agents.map { |agent| agent[:key] } == [running[:key]],
+ "expected only running agent to remain active")
+ assert(response.dig(:body, :archived).all? { |item| item[:archive_path].nil? || Dir.exist?(item[:archive_path]) },
+ "expected archived agents to report archive destinations when logs existed")
+ ensure
+ if running_pid
+ begin
+ Process.kill("TERM", -running_pid)
+ Process.wait(running_pid)
+ rescue Errno::ESRCH, Errno::ECHILD
+ nil
+ end
+ end
+ end
+ end
+
def assert_remote_agent_clone_archives_source_with_editable_name
Dir.mktmpdir("hq-remote-test") do |dir|
old_agents_file = replace_constant(HQ, :AGENTS_FILE, File.join(dir, "managed_agents.json"))
@@ -1033,6 +1107,8 @@ def assert_remote_ui_routes_load_without_auth
assert(legacy_response[:content_type].include?("text/html"), "expected /ui compatibility route to return HTML")
assert(response[:body].include?('name="theme-color" content="#282a36"'),
"expected root shell to expose a PWA theme color")
+ assert(response[:body].include?('content="width=device-width, initial-scale=1, viewport-fit=cover"'),
+ "expected root shell viewport to expose iOS safe-area insets")
assert(response[:body].match?(%r{href="/manifest\.webmanifest\?v=[0-9a-f]{12}"}),
"expected root shell to link a versioned web app manifest")
assert(response[:body].match?(%r{href="/favicon\.png\?v=[0-9a-f]{12}"}),
@@ -1052,6 +1128,10 @@ def assert_remote_ui_routes_load_without_auth
assert(response[:body].match?(%r{src="/ui\.js\?v=[0-9a-f]{12}"}),
"expected root JavaScript reference to be asset-versioned")
assert(response[:body].include?("agent-settings-button"), "expected root shell to expose Agent settings")
+ assert(response[:body].include?('data-tab="settings"'), "expected root shell to expose Settings navigation")
+ assert(response[:body].include?("Settings "), "expected root shell to label setup navigation as Settings")
+ assert(!response[:body].include?('data-tab="search"'), "expected root shell to remove Search navigation")
+ assert(!response[:body].include?('data-tab="projects"'), "expected root shell to remove Projects navigation")
assert(response[:body].include?(" button"),
+ "expected Agents tab actions to align independently from the search width")
+ assert(css[:body].include?("margin-left: auto;"),
+ "expected Agents tab action button to stay right aligned")
+ assert(css[:body].include?(".selectable-agent-row"),
+ "expected Agents tab to style selectable bulk archive rows")
assert(css[:body].include?(".compact-actions"),
"expected Hidden settings rows to support compact action buttons")
assert(css[:body].include?(".schedule-card-body") && css[:body].include?("padding: 0;"),
@@ -1255,22 +1375,22 @@ def assert_remote_ui_routes_load_without_auth
assert(js[:body].include?("normalizeInquiryInputType"),
"expected Remote UI to normalize inquiry field input types")
assert(js[:body].include?("function setAgentSettings"), "expected Agent metadata to move into header settings")
- assert(js[:body].include?("Push notifications"), "expected Setup screen to expose push readiness")
+ assert(js[:body].include?("Push notifications"), "expected Settings screen to expose push readiness")
assert(js[:body].include?("function renderHiddenSettings"),
- "expected Setup screen to expose a dedicated Hidden settings page")
+ "expected Settings screen to expose a dedicated Hidden settings page")
assert(js[:body].include?('apiGet("/settings/hidden")'),
"expected Hidden settings page to load hidden configuration")
assert(js[:body].include?('apiPatch("/settings/hidden"'),
"expected Hidden settings page to update hidden configuration")
assert(js[:body].include?("data-open-hidden-settings"),
- "expected Setup screen to link to Hidden settings")
+ "expected Settings screen to link to Hidden settings")
assert(js[:body].include?('"eyeOff"') && js[:body].include?('"slash"') && js[:body].include?('"eye"'),
"expected Hidden settings to use hidden/inherit/visible icon toggle buttons")
- assert(js[:body].include?("data-restart-server"), "expected Setup screen to expose Remote restart action")
+ assert(js[:body].include?("data-restart-server"), "expected Settings screen to expose Remote restart action")
assert(js[:body].include?('class="danger inline-icon-button restart-server-button" type="button" data-restart-server'),
"expected Remote restart action to use danger button styling")
assert(js[:body].index("Refresh and preferences") < js[:body].index("data-restart-server"),
- "expected Remote restart action to stay at the bottom of the Setup screen")
+ "expected Remote restart action to stay at the bottom of the Settings screen")
assert(js[:body].include?("function restartRemoteServer"), "expected Remote UI to handle Remote restarts")
assert(js[:body].include?('apiPost("/server/restart"'),
"expected Remote UI restart action to call the restart endpoint")
@@ -1361,6 +1481,12 @@ def assert_remote_ui_routes_load_without_auth
assert(js[:body].include?("function brandLogoHtml"), "expected HQ header mark to render the Remote UI logo")
assert(js[:body].include?("function unreadAgents"),
"expected the Remote UI to compute unread agents for the logo popup")
+ assert(js[:body].include?("function syncPlatformClasses"),
+ "expected the Remote UI to detect platform display mode classes")
+ assert(js[:body].include?("navigator.standalone === true"),
+ "expected iPad standalone detection to cover Apple home-screen apps")
+ assert(js[:body].include?("ipad-standalone"),
+ "expected iPad standalone detection to toggle the header spacing class")
assert(js[:body].include?("function toggleUnreadPanel"),
"expected the Remote UI logo to toggle an unread agents popup")
assert(js[:body].include?("function renderUnreadAgentsPanel"),
@@ -1377,6 +1503,12 @@ def assert_remote_ui_routes_load_without_auth
"expected the Remote UI header mark to stay on the brand logo with unread state")
assert(!js[:body].include?("function markHtml"),
"expected page-specific icons to stay out of the header brand mark")
+ assert(js[:body].include?("headerSubtitleIcon"),
+ "expected header subtitle icon state to stay separate from the brand mark")
+ assert(js[:body].include?("function headerSubtitleIconName"),
+ "expected header subtitle icons to normalize legacy header mark aliases")
+ assert(js[:body].include?('state.refreshing ? iconSvg("hourglass") : iconSvg(state.headerSubtitleIcon)'),
+ "expected refresh to swap the subtitle icon without shifting the text")
assert(js[:body].include?("function statusIcon"), "expected readiness marks to use SVG status icons")
assert(js[:body].include?("data-agent-summary"), "expected Agent detail to expose a docked Summary panel")
assert(js[:body].include?("data-preserve-scroll"),
@@ -1439,8 +1571,8 @@ def assert_remote_ui_routes_load_without_auth
"expected Agent detail read state to use the reading endpoint")
assert(js[:body].include?('if (agent.unread) return "unread";'),
"expected unread agents to show unread as the visible status before final run status")
- assert(js[:body].scan('Unread ').length >= 2,
- "expected agent and search lists to render explicit Unread pills")
+ assert(js[:body].scan('Unread ').length >= 1,
+ "expected agent list surfaces to render explicit Unread pills")
assert(js[:body].include?("function shouldAutoScrollAgentConversation"),
"expected Agent detail to auto-scroll only when conversation content changes")
assert(js[:body].include?("function renderConversationBlocks"),
@@ -1545,6 +1677,20 @@ def assert_remote_ui_routes_load_without_auth
"expected Remote UI to support #attachment/:id routes")
assert(js[:body].include?("function renderMarkdown"),
"expected markdown attachments to render as markdown")
+ assert(js[:body].include?("function markdownMessageBlock"),
+ "expected assistant messages and run summaries to opt into markdown rendering")
+ assert(js[:body].include?('block?.kind === "run_summary"'),
+ "expected run summary messages to render as markdown")
+ assert(js[:body].include?('block.role === "assistant"'),
+ "expected assistant messages to render as markdown")
+ assert(js[:body].include?("function renderAgentSummaryContent"),
+ "expected Agent summary text to render as markdown")
+ assert(js[:body].include?('viewerClassName: "markdown-viewer message-markdown-viewer"'),
+ "expected chat markdown to use message-scoped markdown styling")
+ assert(js[:body].include?('viewerClassName: "markdown-viewer summary-markdown-viewer"'),
+ "expected Agent summary markdown to use dock-scoped markdown styling")
+ assert(js[:body].include?("function renderMarkdownRoute"),
+ "expected markdown parser load completion to re-render active markdown routes")
assert(!js[:body].include?("CODE_LANGUAGE_BY_EXTENSION"),
"expected syntax metadata inference to remain out of the attachment viewer")
assert(js[:body].include?("https://cdn.jsdelivr.net/npm/marked@"),
@@ -1610,13 +1756,32 @@ def assert_remote_ui_routes_load_without_auth
"expected Remote UI to scope in-document hash link handling to Markdown viewers")
assert(js[:body].include?("history.replaceState(null, \"\", routeHash(route))"),
"expected Markdown hash links to preserve the attachment route")
- assert(js[:body].include?("function focusSearchInput"), "expected Search tab to focus the search input after render")
- assert(js[:body].include?("sortedAgentGroups(filtered)"),
+ assert(js[:body].include?('const TOP_TABS = ["now", "agents", "settings"];'),
+ "expected Remote UI to simplify top-level tabs")
+ assert(js[:body].include?('if (parts[0] === "search" || parts[0] === "projects") return { type: "tab", tab: "agents" };'),
+ "expected legacy Search and Projects hashes to land on Agents")
+ assert(js[:body].include?('return "#settings/hidden";'),
+ "expected Hidden settings to use the Settings route")
+ assert(js[:body].include?('setHeader("Settings"'),
+ "expected Setup screen to be labeled Settings")
+ assert(js[:body].include?("function agentProjectGroups"),
"expected Agents tab to render project groups in sorted order")
assert(js[:body].include?("function compareAgentProjectKeys"),
"expected Agents tab group sorting to compare project display names")
assert(js[:body].include?("function compareAgentsByName"),
"expected Agents tab to sort agents alphabetically within each project group")
+ assert(js[:body].include?("projectMatches(project, query)"),
+ "expected Agents tab filtering to match project fields")
+ assert(js[:body].include?("renderProjectAgentEmpty()"),
+ "expected Agents tab to keep zero-agent projects reachable")
+ assert(js[:body].include?("data-toggle-bulk-archive"),
+ "expected Agents tab to expose bulk archive selection mode")
+ assert(js[:body].include?("data-run-bulk-archive"),
+ "expected Agents tab to expose bulk archive submission")
+ assert(js[:body].include?('apiPost("/agents/archive", { keys })'),
+ "expected Remote UI to call the bulk archive endpoint")
+ assert(js[:body].include?("function agentArchiveable"),
+ "expected Remote UI to guard running agents from bulk archives")
assert(js[:body].include?("function relativeTimeShort"),
"expected Remote UI list metadata to use compact relative times")
assert(js[:body].include?("function relativeTimeBucket"),
@@ -1625,26 +1790,18 @@ def assert_remote_ui_routes_load_without_auth
"expected Remote UI list metadata to color only relative time tokens")
assert(js[:body].include?("function agentListSubtextHtml"),
"expected Agents tab rows to build dedicated list subtitles")
- assert(js[:body].include?("function agentSearchSubtextHtml"),
- "expected Search agent rows to put relative time first")
- assert(js[:body].include?("function projectSearchSubtext"),
- "expected Search project rows to build dedicated subtitles")
assert(js[:body].include?('class="relative-time ${escapeAttr(bucket)}"'),
"expected Remote UI agent subtitles to render colorable relative time spans")
- assert(!js[:body].include?("Agent / ${agentMeta(agent)}"),
- "expected Search agent rows to omit redundant Agent prefix")
- assert(!js[:body].include?("Project / ${project.status"),
- "expected Search project rows to omit redundant Project prefix")
+ assert(!js[:body].include?("function renderSearch"),
+ "expected Search tab rendering to be removed")
+ assert(!js[:body].include?("function renderProjects"),
+ "expected Projects tab rendering to be removed")
assert(!js[:body].include?("${statusLabel(agent)} / ${agentMeta(agent)}"),
"expected Agents tab rows to omit status from subtext")
assert(!js[:body].include?("agent.project_key, agent.agent, agent.template_key"),
"expected Remote UI list metadata to omit agent template keys")
assert(!js[:body].include?('agent.template_key || "template"'),
"expected Remote UI agent cards to omit agent template keys")
- assert(js[:body].include?("sortedProjects(state.projects)"),
- "expected Projects tab to render projects in sorted order")
- assert(js[:body].include?("function compareProjectsByName"),
- "expected Projects tab sorting to use a stable comparator")
direct_view_writes = js[:body].scan(/els\.view\.innerHTML\s*=\s*(?!\s*html\b)/)
assert(direct_view_writes.empty?, "expected page renderers to use replaceView so polling preserves form state")
assert(!js[:body].include?("detail.open === detail.hasAttribute"),