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(`
- -
- ${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(` - -
${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.")} +
+ ${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(` - - ${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 +
+
+ + +
+
+ `; } -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)}
- + ${project ? `` : ""}
- ${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 ` - `; -} - -function renderSearchResult(result) { - if (result.type === "agent") { - const agent = result.item; - return ` - - `; - } - - const project = result.item; +function renderSelectableAgentRow(agent) { + const selected = state.bulkArchiveSelection.has(agent.key); + const disabled = !agentArchiveable(agent); return ` - + ${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 = `${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 - - -
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"),