diff --git a/packages/views/agents/components/agents-page.tsx b/packages/views/agents/components/agents-page.tsx index 5af23b6f00..2548d1b0b2 100644 --- a/packages/views/agents/components/agents-page.tsx +++ b/packages/views/agents/components/agents-page.tsx @@ -48,18 +48,28 @@ import { CreateAgentDialog } from "./create-agent-dialog"; import { type AgentRow, createAgentColumns } from "./agent-columns"; import { useT } from "../../i18n"; import { matchesPinyin } from "../../editor/extensions/pinyin-match"; +import { + buildRuntimeMachines, + type RuntimeMachine, +} from "../../runtimes/components/runtime-machines"; +import { RuntimeMachineFilterDropdown } from "./runtime-machine-filter-dropdown"; // Filter axes: // -// View = active vs archived dataset. Archived is low-frequency, -// accessed through a ghost link in the toolbar. -// Scope = ownership lens (All vs Mine). Layer-1 segment. -// Availability = "Can the agent take work right now?" — 3-state chip -// group (online / unstable / offline) sourced from -// AgentAvailability. The only chip filter we keep — -// the previous Workload axis was dropped because its -// "queued / failed / cancelled" buckets became -// meaningless once Failed left the workload model. +// View = active vs archived dataset. Archived is low-frequency, +// accessed through a ghost link in the toolbar. +// Scope = ownership lens (All vs Mine). Layer-1 segment. +// Runtime machine = "Which host is the agent bound to?" — dropdown +// filter grouped by section (Local / Remote / Cloud). +// Mirrors the machine grouping on the Runtimes page +// so a user can drill from a machine into the agents +// hosted on it. +// Availability = "Can the agent take work right now?" — 3-state chip +// group (online / unstable / offline) sourced from +// AgentAvailability. The only chip filter we keep — +// the previous Workload axis was dropped because its +// "queued / failed / cancelled" buckets became +// meaningless once Failed left the workload model. type View = "active" | "archived"; type Scope = "all" | "mine"; type AvailabilityFilter = "all" | AgentAvailability; @@ -107,6 +117,11 @@ export function AgentsPage() { const setScope = useAgentsViewStore((s) => s.setScope); const [availabilityFilter, setAvailabilityFilter] = useState("all"); + // `null` means "all runtimes" (the default). When set, the value is a + // RuntimeMachine id from `buildRuntimeMachines` (the same grouping the + // Runtimes page uses), so the user can drill from a machine on that + // page into the agents bound to it. + const [runtimeMachineId, setRuntimeMachineId] = useState(null); const [sort, setSort] = useState("recent"); const [search, setSearch] = useState(""); const [showCreate, setShowCreate] = useState(false); @@ -185,7 +200,71 @@ export function AgentsPage() { return visibleInView.filter((a) => a.owner_id === currentUser.id); }, [visibleInView, scope, currentUser, view]); - // Final cut — availability chip + search. + // Build the workspace's runtime machines (local / remote / cloud + // groupings) the same way the Runtimes page does, so the filter + // dropdown labels match the machines the user sees there. The + // `now` clock only affects health rollups — we don't render health + // chips in this list, so a stale snapshot is fine; a single derive + // per render is cheap and avoids pulling in a 30s tick on a page + // that doesn't show health. + const machines = useMemo( + () => buildRuntimeMachines(runtimes, { now: Date.now() }), + [runtimes], + ); + + // Reverse map: runtime_id → machine id. Lets the filter step look up + // an agent's machine in O(1). Built off the machine grouping rather + // than `runtimesById` so a runtime's machine identity matches the + // dropdown labels (machines dedupe across providers by daemon). + const runtimeIdToMachineId = useMemo(() => { + const m = new Map(); + for (const machine of machines) { + for (const r of machine.runtimes) m.set(r.id, machine.id); + } + return m; + }, [machines]); + + // Per-machine agent counts in `inScope` — used both for the chip + // badges in the dropdown AND to make the runtime filter respect the + // current scope (e.g. "Mine" only shows machines that have one of + // my agents). Computed against `inScope` (not `visibleInView`) so the + // number next to "All" is exactly `inScope.length`. + const agentCountByMachine = useMemo(() => { + const counts = new Map(); + for (const a of inScope) { + const machineId = runtimeIdToMachineId.get(a.runtime_id); + if (!machineId) continue; + counts.set(machineId, (counts.get(machineId) ?? 0) + 1); + } + return counts; + }, [inScope, runtimeIdToMachineId]); + + // If the selected machine is GC'd while we're on the page (daemon + // stopped, runtime deleted), the filter would zero out the list with + // no UI to clear it. Bounce back to "all" so the user always sees + // something actionable. + useEffect(() => { + if ( + runtimeMachineId !== null && + !machines.some((machine) => machine.id === runtimeMachineId) + ) { + setRuntimeMachineId(null); + } + }, [runtimeMachineId, machines]); + + // Resolved title for the current machine filter — used by the + // no-matches state so the user sees "No agents on `dev.local`" rather + // than a bare "No agents match this filter" when the search is empty + // but the machine filter is doing the narrowing. + const selectedMachine = useMemo( + () => + runtimeMachineId === null + ? null + : machines.find((machine) => machine.id === runtimeMachineId) ?? null, + [runtimeMachineId, machines], + ); + + // Final cut — availability chip + runtime machine + search. const filteredAgents = useMemo(() => { const q = search.trim().toLowerCase(); return inScope.filter((a) => { @@ -195,6 +274,16 @@ export function AgentsPage() { const detail = presenceMap.get(a.id); if (detail?.availability !== availabilityFilter) return false; } + // Runtime machine filter only applies to the Active view — the + // archived toolbar has no machine dropdown, so an archived agent + // would never get selected but might still slip through scope. + if ( + view === "active" && + runtimeMachineId !== null && + runtimeIdToMachineId.get(a.runtime_id) !== runtimeMachineId + ) { + return false; + } if (q) { if ( !a.name.toLowerCase().includes(q) && @@ -206,7 +295,15 @@ export function AgentsPage() { } return true; }); - }, [inScope, view, availabilityFilter, presenceMap, search]); + }, [ + inScope, + view, + availabilityFilter, + presenceMap, + runtimeMachineId, + runtimeIdToMachineId, + search, + ]); // Per-availability counts for the chip badges. Computed against // `inScope` (ignoring the availability filter itself) so the numbers @@ -421,6 +518,10 @@ export function AgentsPage() { totalCount={inScope.length} archivedCount={archivedCount} onShowArchived={() => setView("archived")} + machines={machines} + runtimeMachineId={runtimeMachineId} + onRuntimeMachineChange={setRuntimeMachineId} + agentCountByMachine={agentCountByMachine} /> + ) : ( void; @@ -577,6 +687,10 @@ function ActiveToolbarRow({ totalCount: number; archivedCount: number; onShowArchived: () => void; + machines: RuntimeMachine[]; + runtimeMachineId: string | null; + onRuntimeMachineChange: (id: string | null) => void; + agentCountByMachine: Map; }) { const { t } = useT("agents"); return ( @@ -592,6 +706,12 @@ function ActiveToolbarRow({
+ {archivedCount > 0 && ( + ); +} diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 7afa2016bb..dcc54b003a 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -46,7 +46,21 @@ "no_archived": "No archived agents yet.", "search_active": "No agents match \"{{query}}\".", "search_active_filtered": "No agents match \"{{query}}\" in this filter.", - "no_filter_match": "No agents match this filter." + "no_filter_match": "No agents match this filter.", + "search_runtime_filtered": "No agents on \"{{machine}}\" match \"{{query}}\".", + "runtime_filtered": "No agents on \"{{machine}}\"." + }, + "runtime_filter": { + "all": "All runtimes", + "all_description": "Agents on any machine", + "section_local": "Local", + "section_remote": "Remote", + "section_cloud": "Cloud", + "agent_count_one": "{{count}} agent", + "agent_count_other": "{{count}} agents", + "this_machine": "This machine", + "empty": "No machines yet", + "reset": "All runtimes" }, "columns": { "agent": "Agent", diff --git a/packages/views/locales/ko/agents.json b/packages/views/locales/ko/agents.json index ad0dc6ec8b..5bf054a910 100644 --- a/packages/views/locales/ko/agents.json +++ b/packages/views/locales/ko/agents.json @@ -46,7 +46,21 @@ "no_archived": "아직 보관된 에이전트가 없습니다.", "search_active": "\"{{query}}\"와 일치하는 에이전트가 없습니다.", "search_active_filtered": "이 필터에서 \"{{query}}\"와 일치하는 에이전트가 없습니다.", - "no_filter_match": "이 필터와 일치하는 에이전트가 없습니다." + "no_filter_match": "이 필터와 일치하는 에이전트가 없습니다.", + "search_runtime_filtered": "\"{{machine}}\"에서 \"{{query}}\"와 일치하는 에이전트가 없습니다.", + "runtime_filtered": "\"{{machine}}\"에 에이전트가 없습니다." + }, + "runtime_filter": { + "all": "모든 런타임", + "all_description": "모든 기기의 에이전트", + "section_local": "로컬", + "section_remote": "원격", + "section_cloud": "클라우드", + "agent_count_one": "에이전트 {{count}}개", + "agent_count_other": "에이전트 {{count}}개", + "this_machine": "이 기기", + "empty": "아직 기기가 없습니다", + "reset": "모든 런타임" }, "columns": { "agent": "에이전트", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index a741b8d7ee..382c7c06f3 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -46,7 +46,20 @@ "no_archived": "还没有已归档智能体。", "search_active": "没有智能体匹配\"{{query}}\"。", "search_active_filtered": "在该筛选下没有智能体匹配\"{{query}}\"。", - "no_filter_match": "该筛选下没有匹配的智能体。" + "no_filter_match": "该筛选下没有匹配的智能体。", + "search_runtime_filtered": "\"{{machine}}\"上没有匹配\"{{query}}\"的智能体。", + "runtime_filtered": "\"{{machine}}\"上还没有智能体。" + }, + "runtime_filter": { + "all": "全部运行时", + "all_description": "所有机器上的智能体", + "section_local": "本机", + "section_remote": "远程", + "section_cloud": "云端", + "agent_count_other": "{{count}} 个智能体", + "this_machine": "本机", + "empty": "还没有机器", + "reset": "全部运行时" }, "columns": { "agent": "智能体",