From a19fad6b024ec5aa52b3e4fe42af7e81c3e9d3a3 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Wed, 30 Sep 2026 15:13:19 +0530 Subject: [PATCH] feat(agents): pin agents to the top of the Agents list (v0.449.0) Per-member agentPins in member_prefs (beside navPins), shipped on /api/auth/me and saved via PUT /api/me/agent-pins. The Agents page lifts pinned agents into a leading Pinned group in pin order (gallery + list), with hover pin toggles and a pin button in the composer header. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++++++ package-lock.json | 4 +-- package.json | 2 +- src/governance/team.ts | 16 ++++++++++- src/server.ts | 11 ++++++-- src/types.ts | 19 +++++++++++++ web/src/App.tsx | 61 ++++++++++++++++++++++++++++++++++++------ web/src/lib/api.ts | 7 ++++- 8 files changed, 115 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2063d7de..f164cb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ new version heading in the same commit. ## [Unreleased] +## [0.449.0] - 2026-09-30 +### Added +- **Pin agents to the top of the Agents list.** Each member can pin the agents they use most; pinned + agents lift out of their category into a leading "Pinned" group (in pin order), in both the gallery + and list views, and become the default selection on a bare `#/agents` when you have no last-used + agent. Toggle from the hover pin on any row/card or the pin button in the agent's composer header. + Stored per member in `member_prefs.agentPins` (beside `navPins`), shipped on `/api/auth/me`, saved via + `PUT /api/me/agent-pins`. Display-only — pinning grants nothing. + **For users:** You can now pin your go-to agents so they always sit at the top of the Agents list. [Open Agents](#/agents) + ## [0.448.7] - 2026-09-29 ### Fixed - **The gate now reads the scripts an agent runs, not just the command that runs them.** On globex a diff --git a/package-lock.json b/package-lock.json index 55104543..377be568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.448.7", + "version": "0.449.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.448.7", + "version": "0.449.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 1d675b90..566a818e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.448.7", + "version": "0.449.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/governance/team.ts b/src/governance/team.ts index 3ca2fcc5..67d01ea7 100644 --- a/src/governance/team.ts +++ b/src/governance/team.ts @@ -10,7 +10,7 @@ import { randomBytes } from 'crypto'; import { newId } from '../id'; import { Db } from '../state/db'; -import { AgentAccess, Member, MemberIdentity, IdentityProvider, Role, ApprovalLevel, canApprove, NotificationPrefs, sanitizeNotificationPrefs, sanitizeNavPins, PromptShortcut, sanitizePromptShortcuts } from '../types'; +import { AgentAccess, Member, MemberIdentity, IdentityProvider, Role, ApprovalLevel, canApprove, NotificationPrefs, sanitizeNotificationPrefs, sanitizeNavPins, sanitizeAgentPins, PromptShortcut, sanitizePromptShortcuts } from '../types'; const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // magic links valid for 7 days const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // login cookie valid for 30 days @@ -123,6 +123,20 @@ export class TeamStore { return clean; } + /** The agents this member pinned to the top of their Agents list, in pin order ([] when never set). + * See `sanitizeAgentPins`. */ + agentPins(memberId: string): string[] { + return sanitizeAgentPins(this.rawPrefs(memberId).agentPins); + } + + /** Persist the member's pinned agents (sanitized, deduped, order kept), preserving sibling prefs in + * the same blob. Returns the resolved list. */ + setAgentPins(memberId: string, pins: unknown): string[] { + const clean = sanitizeAgentPins(pins); + this.writeRawPrefs(memberId, { ...this.rawPrefs(memberId), agentPins: clean }); + return clean; + } + /** This member's saved prompt shortcuts (the Quick Shortcuts strip in the terminal). Personal, stored * alongside the rest of their prefs; empty list when never set. */ promptShortcuts(memberId: string): PromptShortcut[] { diff --git a/src/server.ts b/src/server.ts index f50152b5..7588667e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -696,9 +696,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const headers: Record = { 'content-type': 'application/json; charset=utf-8' }; if (sid) headers['set-cookie'] = sessionCookie(sid); res.writeHead(200, headers); - // navPins rides along on the auth payload (not a separate fetch) so the sidebar's pinned layout is + // navPins + agentPins ride along on the auth payload (not a separate fetch) so the sidebar's pinned layout is // known at first shell paint — no flash of the default nav before a follow-up request lands. - res.end(JSON.stringify({ member: m, navPins: os.team.navPins(m.id) })); + res.end(JSON.stringify({ member: m, navPins: os.team.navPins(m.id), agentPins: os.team.agentPins(m.id) })); return; } // Per-tenant console branding (accent colour + favicon badge). PUBLIC + display-only (no secrets): @@ -3887,6 +3887,13 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: return sendJson(res, 200, { pinned: os.team.setNavPins(me.id, (b as { pinned?: unknown }).pinned) }); } + // This member's pinned agents (floated to the top of their Agents list). Per person, not admin-gated; + // display-only — pinning an agent you can't run grants nothing. Initial value ships on /api/auth/me. + if (method === 'PUT' && p === '/api/me/agent-pins') { + const b = await readBody(req); + return sendJson(res, 200, { pinned: os.team.setAgentPins(me.id, (b as { pinned?: unknown }).pinned) }); + } + // Dismiss the whole Activity feed at once (soft hide). Leaves action-required items (pending // approvals/questions, waiting notifications) in place. Same per-viewer visibility as the feed. if (method === 'POST' && p === '/api/messages/dismiss-all') { diff --git a/src/types.ts b/src/types.ts index f2198bd9..5621e73e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -113,6 +113,25 @@ export function sanitizeNavPins(input: unknown): string[] | null { return out; } +/** + * Which agents a member has pinned to the top of their Agents list — a per-member preference stored in + * `member_prefs` beside `navPins`. Values are agent ids in the member's chosen order; the server only + * validates shape (short, deduped strings) and never prunes ids of agents that were since deleted or + * unshared — the console simply skips an id it can't see, so a re-shared agent comes back pinned. + * Personal display state only: pinning grants nothing. + */ +export function sanitizeAgentPins(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const out: string[] = []; + for (const v of input) { + if (typeof v !== 'string') continue; + const s = v.trim(); + if (s && s.length <= 128 && !out.includes(s)) out.push(s); + if (out.length >= 100) break; + } + return out; +} + /** * A member-defined prompt shortcut — a named canned prompt they can fire into a live terminal session * with one click (the console's Quick Shortcuts strip). Purely a personal convenience stored in diff --git a/web/src/App.tsx b/web/src/App.tsx index 8d23e449..f6412c46 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -689,6 +689,26 @@ type AgentsView = 'grid' | 'split' /** Bucket agents by their category label for the grouped picker. Uncategorised agents fall into a * trailing "Uncategorized" group; named categories sort alphabetically, each group keeping list order. */ +/** Header of the Agents list's leading group — the member's pinned agents, lifted out of their categories. */ +const PINNED_GROUP = 'Pinned' + +/** Hover pin toggle overlaid on an agent row/card. A sibling of the row's link (never nested inside it), + * so clicking it doesn't navigate. Hover-only: the "Pinned" group header already shows the state. */ +function AgentPinButton({ pinned, label, onToggle, className = '' }: { pinned: boolean; label: string; onToggle: () => void; className?: string }) { + return ( + + ) +} + function groupByCategory(agents: AgentInfo[]): [string, AgentInfo[]][] { const UNCATEGORIZED = 'Uncategorized' const buckets = new Map() @@ -2602,6 +2622,15 @@ function AgentsPage({ const [view, setView] = useState(() => (localStorage.getItem(AGENTS_VIEW_KEY) === 'grid' ? 'grid' : 'split')) const setViewPersist = (v: AgentsView) => { setView(v); localStorage.setItem(AGENTS_VIEW_KEY, v) } const [query, setQuery] = useState('') + // Per-member pinned agents — floated into a "Pinned" group above the categories, in pin order. + // Seeded from /api/auth/me; toggled optimistically and saved (a failed save rolls back). + const [pins, setPins] = useState(() => me.agentPins ?? []) + const togglePin = (id: string) => { + const prev = pins + const next = pins.includes(id) ? pins.filter((x) => x !== id) : [...pins, id] + setPins(next) + api.saveAgentPins(next).then((r) => setPins(r.pinned)).catch(() => setPins(prev)) + } // Fleet-wide maturity, keyed by agent id — the trust-at-a-glance signal on each agent chip. const [maturity, setMaturity] = useState>({}) useEffect(() => { @@ -2647,11 +2676,12 @@ function AgentsPage({ }, [selected]) // The chosen agent is driven by the URL (`#/agents/`) so a refresh keeps it. When the URL names - // no agent (a bare `#/agents`), fall back to the last one you used (remembered across visits) then - // the first in the list — without rewriting the URL, so the default doesn't spam history. + // no agent (a bare `#/agents`), fall back to the last one you used (remembered across visits), then + // your first pinned agent, then the first in the list — without rewriting the URL, so the default doesn't spam history. const has = (id: string) => agents.some((a) => a.id === id) const lastUsed = localStorage.getItem(LAST_AGENT_KEY) - const agentId = has(selected) ? selected : (lastUsed && has(lastUsed) ? lastUsed : (agents[0]?.id ?? '')) + const firstPinned = pins.find(has) + const agentId = has(selected) ? selected : (lastUsed && has(lastUsed) ? lastUsed : (firstPinned ?? agents[0]?.id ?? '')) const agent = agents.find((a) => a.id === agentId) const pick = (id: string) => { localStorage.setItem(LAST_AGENT_KEY, id); onSelect(id) } @@ -2719,7 +2749,13 @@ function AgentsPage({ const filtered = q ? agents.filter((a) => a.id.toLowerCase().includes(q) || (a.description ?? '').toLowerCase().includes(q) || (a.category ?? '').toLowerCase().includes(q)) : agents - const groups = groupByCategory(filtered) + // Pinned agents lift out of their category into a leading "Pinned" group (pin order); an id for an + // agent that's gone or no longer shared with you is simply skipped. + const pinnedList = pins.map((id) => filtered.find((a) => a.id === id)).filter((a): a is AgentInfo => !!a) + const groups: [string, AgentInfo[]][] = [ + ...(pinnedList.length ? [[PINNED_GROUP, pinnedList] as [string, AgentInfo[]]] : []), + ...groupByCategory(filtered.filter((a) => !pins.includes(a.id))), + ] // The task composer for the selected agent — shared by both layouts (the gallery puts it below the // cards; the split view puts it in the right pane). Its per-agent Edit/Delete actions live here. @@ -2733,6 +2769,9 @@ function AgentsPage({ {agent.builtIn && }
+ {(proposalCounts[agent.id] ?? 0) > 0 && (