From 9c7cde9b9a93d1015a748f96751db75acc2c0059 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:55:50 +0000 Subject: [PATCH 1/2] Agent menu: most-used or pinned agents, the rest under More agents The composer's agent dropdown listed every local agent (two columns past six). It now shows the pinned agents, or the six most-used local ones until the person pins any, plus a More agents row that opens the full searchable list with a pin toggle per agent. Picks and pins are remembered per browser (ui/src/agentPicks.ts). The phone's agent sheet gains a search box and counts picks too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UhoMM8gopAmsPkcxdBbcze --- docs/changelog.json | 6 + ui/src/agentPicks.ts | 78 +++++++++++ ui/src/components/composer/ComposerMenus.tsx | 131 +++++++++++++++++-- ui/src/i18n/fr.json | 13 +- ui/src/mobile/MobileAgentPicker.tsx | 25 +++- 5 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 ui/src/agentPicks.ts diff --git a/docs/changelog.json b/docs/changelog.json index 96f29f0..3483dad 100644 --- a/docs/changelog.json +++ b/docs/changelog.json @@ -1,4 +1,10 @@ [ + { + "date": "2026-10-13", + "area": "Chat", + "title": "The agent menu shows the agents you use, with the rest under More agents", + "summary": "The agent menu above the message box now lists your six most-used agents instead of every agent at once. More agents opens the full list with a search box; pin agents there to choose exactly which ones the menu shows, or go back to the most-used ones. Cloud agents stay under Cloud unless you pin them. On a phone, the agent sheet gains a search box. The choice is remembered per browser." + }, { "date": "2026-10-13", "area": "Chat", diff --git a/ui/src/agentPicks.ts b/ui/src/agentPicks.ts new file mode 100644 index 0000000..95f6456 --- /dev/null +++ b/ui/src/agentPicks.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import type { Agent } from './types'; + +// Which agents the composer's agent dropdown lists (components/composer/ComposerMenus.tsx). Per browser, like the +// ambience prefs: `pinned` is the person's own choice from "More agents" (null = not chosen yet, so the dropdown +// shows the most-used ones), `uses` counts picks from the dropdown and the phone's agent sheet. +export interface AgentPicks { + pinned: string[] | null; + uses: Record; +} + +const KEY = 'shellb.agentPicks'; +const EVENT = 'shellb-agent-picks'; +/** How many agents the dropdown shows before the person pins their own. */ +export const QUICK_COUNT = 6; + +export function loadAgentPicks(): AgentPicks { + try { + const p = JSON.parse(localStorage.getItem(KEY) || '{}'); + return { pinned: Array.isArray(p.pinned) ? p.pinned : null, uses: p.uses && typeof p.uses === 'object' ? p.uses : {} }; + } catch { + return { pinned: null, uses: {} }; + } +} + +function save(next: AgentPicks) { + try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* private mode */ } + window.dispatchEvent(new CustomEvent(EVENT, { detail: next })); +} + +export function recordAgentPick(id: string) { + const p = loadAgentPicks(); + save({ ...p, uses: { ...p.uses, [id]: (p.uses[id] ?? 0) + 1 } }); +} + +/** Pin or unpin one agent. The first pin starts from what the dropdown showed, so nothing disappears unasked. */ +export function toggleAgentPin(id: string, shown: string[]) { + const p = loadAgentPicks(); + const pinned = p.pinned ?? shown; + save({ ...p, pinned: pinned.includes(id) ? pinned.filter(x => x !== id) : [...pinned, id] }); +} + +/** Back to the most-used agents. */ +export function resetAgentPins() { + save({ ...loadAgentPicks(), pinned: null }); +} + +export function useAgentPicks(): AgentPicks { + const [picks, setPicks] = useState(loadAgentPicks); + useEffect(() => { + const on = (e: Event) => setPicks((e as CustomEvent).detail); + const onStorage = (e: StorageEvent) => { if (e.key === KEY) setPicks(loadAgentPicks()); }; + window.addEventListener(EVENT, on); + window.addEventListener('storage', onStorage); + return () => { window.removeEventListener(EVENT, on); window.removeEventListener('storage', onStorage); }; + }, []); + return picks; +} + +/** The dropdown's agents: the pinned ones in pin order, or else the most-used local agents (roster order breaks ties). + * Cloud agents only appear here when pinned; otherwise they stay in the Cloud submenu. */ +export function quickAgents(agents: Agent[], picks: AgentPicks): Agent[] { + if (picks.pinned) { + const byId = new Map(agents.map(a => [a.id, a])); + return picks.pinned.map(id => byId.get(id)).filter((a): a is Agent => !!a); + } + return agents + .filter(a => !a.cloud) + .map((a, i) => ({ a, i, n: picks.uses[a.id] ?? 0 })) + .sort((x, y) => y.n - x.n || x.i - y.i) + .slice(0, QUICK_COUNT) + .sort((x, y) => x.i - y.i) + .map(x => x.a); +} + +/** Case-insensitive match on name, title and description. */ +export const matchesAgent = (a: Agent, q: string) => + !q || `${a.name} ${a.title} ${a.description}`.toLowerCase().includes(q.trim().toLowerCase()); diff --git a/ui/src/components/composer/ComposerMenus.tsx b/ui/src/components/composer/ComposerMenus.tsx index 5579271..e07e2b2 100644 --- a/ui/src/components/composer/ComposerMenus.tsx +++ b/ui/src/components/composer/ComposerMenus.tsx @@ -10,9 +10,13 @@ import { Cloud, Cpu, ListChecks, + MoreHorizontal, + Pin, + Search, } from 'lucide-react'; import type { Agent, Effort, ModelInfo } from '../../types'; import { AgentAvatar } from '../common'; +import { matchesAgent, quickAgents, recordAgentPick, resetAgentPins, toggleAgentPin, useAgentPicks } from '../../agentPicks'; export const EFFORTS: { id: Effort; label: string; hint: string }[] = [ { id: 'off', label: 'Instant', hint: 'No deliberation' }, @@ -50,23 +54,39 @@ export function ComposerMenus({ }: ComposerMenusProps) { const [menu, setMenu] = useState<'agent' | 'effort' | 'model' | null>(null); const [cloudOpen, setCloudOpen] = useState(false); + // 'quick': the pinned or most-used agents; 'all': every agent, searchable, with pins + const [view, setView] = useState<'quick' | 'all'>('quick'); + const [query, setQuery] = useState(''); + const picks = useAgentPicks(); useEffect(() => { - if (menu !== 'agent') setCloudOpen(false); + if (menu !== 'agent') { + setCloudOpen(false); + setView('quick'); + setQuery(''); + } }, [menu]); - const localAgents = agents.filter(a => !a.cloud); - const cloudAgents = agents.filter(a => a.cloud); + const quick = quickAgents(agents, picks); + // the agent in use always shows, even when it isn't pinned (a cloud one already shows on the Cloud row) + if (!agent.cloud && !quick.some(a => a.id === agent.id) && agents.some(a => a.id === agent.id)) quick.unshift(agent); + const quickIds = quick.map(a => a.id); + const cloudAgents = agents.filter(a => a.cloud && !quickIds.includes(a.id)); + const hidden = agents.length - quick.length - cloudAgents.length; + const found = [...agents.filter(a => !a.cloud), ...agents.filter(a => a.cloud)].filter(a => matchesAgent(a, query)); + + const choose = (a: Agent) => { + recordAgentPick(a.id); + onAgent(a); + setMenu(null); + }; const agentItem = (a: Agent) => ( ); + const pinnedRow = (a: Agent) => { + const pinned = quickIds.includes(a.id); + return ( +
+
{agentItem(a)}
+ +
+ ); + }; + return ( <>
@@ -102,12 +144,58 @@ export function ComposerMenus({
6 && 'sm:w-[36rem]' // two columns, so the whole roster fits without scrolling + view === 'all' && 'sm:w-80' )} role="menu" > -
6 && 'sm:grid sm:grid-cols-2 sm:gap-x-0.5')}> - {localAgents.map(agentItem)} + {view === 'all' ? ( + <> +
+ + +
+
+ {found.map(pinnedRow)} + {found.length === 0 && ( +
{t('No agent matches')}
+ )} +
+
+ + {picks.pinned ? t('Pinned agents show in the menu') : t('The menu shows your most-used agents; pin some to choose')} + + {picks.pinned && ( + + )} +
+ + ) : ( + <> +
+ {quick.map(agentItem)}
{cloudAgents.length > 0 && (
setCloudOpen(true)} onMouseLeave={() => setCloudOpen(false)} > - {localAgents.length > 0 &&
} + {quick.length > 0 &&
}
)} +
+ + + )}
)}
diff --git a/ui/src/i18n/fr.json b/ui/src/i18n/fr.json index 1c7c408..8c0e299 100644 --- a/ui/src/i18n/fr.json +++ b/ui/src/i18n/fr.json @@ -4003,5 +4003,16 @@ "Yours": "Les vôtres", "maintained with Shell:B, always listed": "maintenus avec Shell:B, toujours listés", "not fetched yet": "pas encore récupéré", - "{n} items": "{n} éléments" + "{n} items": "{n} éléments", + "Search agents": "Rechercher un agent", + "No agent matches": "Aucun agent ne correspond", + "More agents": "Plus d’agents", + "{n} more": "{n} de plus", + "Remove from the menu": "Retirer du menu", + "Show in the menu": "Afficher dans le menu", + "Remove {name} from the menu": "Retirer {name} du menu", + "Show {name} in the menu": "Afficher {name} dans le menu", + "Pinned agents show in the menu": "Les agents épinglés s’affichent dans le menu", + "The menu shows your most-used agents; pin some to choose": "Le menu affiche vos agents les plus utilisés ; épinglez-en pour choisir", + "Use most-used": "Les plus utilisés" } diff --git a/ui/src/mobile/MobileAgentPicker.tsx b/ui/src/mobile/MobileAgentPicker.tsx index 7729200..9e1e561 100644 --- a/ui/src/mobile/MobileAgentPicker.tsx +++ b/ui/src/mobile/MobileAgentPicker.tsx @@ -1,8 +1,10 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import clsx from 'clsx'; -import { Check, X, Sparkles, Cloud } from 'lucide-react'; +import { Check, X, Sparkles, Cloud, Search } from 'lucide-react'; import type { Agent } from '../types'; import { AgentAvatar } from '../components/common'; +import { matchesAgent, recordAgentPick } from '../agentPicks'; +import { t } from '../i18n'; interface Props { open: boolean; @@ -13,8 +15,9 @@ interface Props { } export function MobileAgentPicker({ open, onClose, agents, activeId, onSelect }: Props) { + const [query, setQuery] = useState(''); useEffect(() => { - if (!open) return; + if (!open) { setQuery(''); return; } const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -45,13 +48,27 @@ export function MobileAgentPicker({ open, onClose, agents, activeId, onSelect }: + {agents.length > 6 && ( + + )} +
- {agents.map(a => { + {agents.filter(a => matchesAgent(a, query)).map(a => { const active = a.id === activeId; return ( ))} - +
+ {store ? ( +
+ { pinAgent(id, quickAgents(agents, loadAgentPicks()).map(a => a.id)); onChanged(); }} + onOpen={id => { setStore(false); setIsNew(false); setSelId(id); }} + onChat={onChat ? id => { onChat(id); onClose(); } : undefined} /> +
+ ) : (<> +
@@ -279,9 +304,10 @@ export function AgentLab({ open, onClose, agents, tools, models, initialId, onCh {error &&
{error}
}
+ )}
-
+ {!store &&
{!isNew && } {!isNew && draft.builtin && ( @@ -298,7 +324,7 @@ export function AgentLab({ open, onClose, agents, tools, models, initialId, onCh {isNew && }
-
+
} ); } diff --git a/ui/src/components/Gallery.tsx b/ui/src/components/Gallery.tsx index b1104f6..272571e 100644 --- a/ui/src/components/Gallery.tsx +++ b/ui/src/components/Gallery.tsx @@ -1,15 +1,15 @@ -// Community gallery (server/gallery.py): themes and screensavers published on GitHub, browsed from Settings → -// Appearance and installed with one click. Themes arrive as data and go through the server's validator; screensavers +// Community gallery (server/gallery.py): themes, screensavers, templates and agents published on GitHub, browsed from +// Settings → Appearance, the Studio and the Agent Lab's Store, and installed with one click. Themes arrive as data and go through the server's validator; screensavers // become Screensaver projects in the Studio. Installing needs the owner (or the admin key), browsing doesn't. import { useCallback, useEffect, useState } from 'react'; import clsx from 'clsx'; -import { Check, Download, ExternalLink, Globe, MonitorCheck, MonitorPlay, RefreshCw } from 'lucide-react'; +import { Check, Download, ExternalLink, Globe, MessageSquare, MonitorCheck, MonitorPlay, RefreshCw, Search } from 'lucide-react'; import { api, req } from '../api'; -import type { Settings } from '../types'; +import type { Agent, Settings, ToolInfo } from '../types'; import { customInfo, loadCustomThemes, setTheme, useTheme, type CustomTheme, type Mode } from '../themes'; import { PREVIEW_SAVER, loadAmbience, saveAmbience, useAmbience } from '../ambience'; import { Thumb } from './ThemePicker'; -import { Modal, btn } from './common'; +import { AgentAvatar, Modal, btn } from './common'; import { toast } from './toast'; import { t as tx } from '../i18n'; @@ -18,10 +18,12 @@ interface Item { id: string; name: string; blurb: string; author: string; author export interface GalleryTheme extends Item { mode: Mode; theme: CustomTheme } export interface GallerySaver extends Item { clockSafe: boolean; preview: string | null; files: string[] } export interface GalleryTemplate extends Item { kind: string; entry: string; brief: string; preview: string | null; files: string[] } +export interface GalleryAgent extends Item { title: string; avatar: string; color: [string, string]; needs: string[]; model: string; + effort: string; tools: string[] } interface Source { source: string; repo: string; sha: string; fetched: number; url: string; items: T[] } interface Listing { kind: string; sources: Source[]; errors: string[] } -function useListing(kind: 'theme' | 'screensaver' | 'template', open = true) { +function useListing(kind: 'theme' | 'screensaver' | 'template' | 'agent', open = true) { const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -272,3 +274,97 @@ export function TemplatePicker({ kind, name, onCreated }: { kind: string; name:
); } + +/** Agent Lab → Store: ready-made agents from the community repositories. Installing adds the agent to the roster and + * to the agent menu; it runs on this Shell:B's own model (server/gallery.py install_agent). Written for someone who has + * never opened the Agent Lab: what the agent is for, what it can use, one button. */ +export function AgentStore({ agents, tools, onInstalled, onOpen, onChat }: { + agents: Agent[]; + tools: ToolInfo[]; + onInstalled: (id: string) => void; + onOpen: (id: string) => void; + onChat?: (id: string) => void; +}) { + const { data, loading, error, load } = useListing('agent'); + const [busy, setBusy] = useState(''); + const [query, setQuery] = useState(''); + const [tag, setTag] = useState(''); + const toolName = (id: string) => tools.find(t => t.id === id)?.displayName ?? id.replace(/_/g, ' '); + const all = (data?.sources ?? []).flatMap(s => s.items.map(it => ({ source: s.source, it }))); + const tags = [...new Set(all.flatMap(x => x.it.tags))].sort(); + const q = query.trim().toLowerCase(); + const shown = all.filter(({ it }) => (!tag || it.tags.includes(tag)) + && (!q || `${it.name} ${it.title} ${it.blurb} ${it.tags.join(' ')}`.toLowerCase().includes(q))); + const install = async (source: string, it: GalleryAgent) => { + setBusy(`${source}:${it.id}`); + try { + const res = await req<{ agent: Agent; notes: string[] }>('/api/gallery/install', { method: 'POST', body: JSON.stringify({ kind: 'agent', source, id: it.id }) }); + onInstalled(res.agent.id); + toast(res.notes.length ? `${tx('{name} is in your agent menu.', { name: it.name })} ${res.notes.join(' ')}` : tx('{name} is in your agent menu.', { name: it.name })); + load(); + } catch (e) { installError(e); } finally { setBusy(''); } + }; + return ( +
+
load(true)} /> +

+ {tx('Each agent is a set of instructions that runs on your own model, here on this machine. Installing one adds it to your agent menu; you can change or delete it any time.')} +

+
+ + {tags.length > 1 && ['', ...tags].map(tg => ( + + ))} +
+ + {data && all.length > 0 && shown.length === 0 &&
{tx('No agent matches')}
} +
+ {shown.map(({ source, it }) => { + const key = `${source}:${it.id}`; + const mine = it.installed ? agents.find(a => a.id === it.installed) : undefined; + const update = !!mine && it.installedVersion !== it.version; + return ( +
+
+ +
+
{it.name} · {it.title}
+
{it.blurb}
+
+
+ {it.tools.length > 0 && ( +
+ {tx('Can use: {tools}', { tools: it.tools.map(toolName).join(', ') })} +
+ )} +
+ +
+ {mine && onChat && !update && ( + + )} + {mine && !update && ( + + )} + {(!mine || update) && ( + + )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/ui/src/components/composer/ComposerMenus.tsx b/ui/src/components/composer/ComposerMenus.tsx index e07e2b2..1b459e1 100644 --- a/ui/src/components/composer/ComposerMenus.tsx +++ b/ui/src/components/composer/ComposerMenus.tsx @@ -13,10 +13,11 @@ import { MoreHorizontal, Pin, Search, + Store, } from 'lucide-react'; import type { Agent, Effort, ModelInfo } from '../../types'; import { AgentAvatar } from '../common'; -import { matchesAgent, quickAgents, recordAgentPick, resetAgentPins, toggleAgentPin, useAgentPicks } from '../../agentPicks'; +import { matchesAgent, openAgentStore, quickAgents, recordAgentPick, resetAgentPins, toggleAgentPin, useAgentPicks } from '../../agentPicks'; export const EFFORTS: { id: Effort; label: string; hint: string }[] = [ { id: 'off', label: 'Instant', hint: 'No deliberation' }, @@ -191,6 +192,14 @@ export function ComposerMenus({ )}
+ ) : ( <> diff --git a/ui/src/i18n/fr.json b/ui/src/i18n/fr.json index d0652a7..37d491f 100644 --- a/ui/src/i18n/fr.json +++ b/ui/src/i18n/fr.json @@ -4046,5 +4046,12 @@ "Show {name} in the menu": "Afficher {name} dans le menu", "Pinned agents show in the menu": "Les agents épinglés s’affichent dans le menu", "The menu shows your most-used agents; pin some to choose": "Le menu affiche vos agents les plus utilisés ; épinglez-en pour choisir", - "Use most-used": "Les plus utilisés" + "Use most-used": "Les plus utilisés", + "Agent Store": "Boutique d’agents", + "Browse the Agent Store": "Parcourir la boutique d’agents", + "Ready-made agents for writing, studying, planning, games and more": "Des agents prêts à l’emploi pour écrire, étudier, organiser, jouer et plus encore", + "Each agent is a set of instructions that runs on your own model, here on this machine. Installing one adds it to your agent menu; you can change or delete it any time.": "Chaque agent est un ensemble d’instructions qui tourne sur votre propre modèle, ici sur cette machine. L’installer l’ajoute à votre menu d’agents ; vous pouvez le modifier ou le supprimer à tout moment.", + "No agents published yet. Make one in the Agent Lab and share it on GitHub.": "Aucun agent publié pour l’instant. Créez-en un dans le Labo des agents et partagez-le sur GitHub.", + "Can use: {tools}": "Peut utiliser : {tools}", + "{name} is in your agent menu.": "{name} est dans votre menu d’agents." } diff --git a/ui/src/kiosk/KioskApp.tsx b/ui/src/kiosk/KioskApp.tsx index fa8b520..9cf77a7 100644 --- a/ui/src/kiosk/KioskApp.tsx +++ b/ui/src/kiosk/KioskApp.tsx @@ -48,6 +48,7 @@ import { Feeds } from '../components/Feeds'; import { MapsApp } from '../maps/MapsApp'; import { RadioApp } from '../radio/RadioApp'; import { AgentLab } from '../components/AgentLab'; +import { OPEN_AGENT_STORE } from '../agentPicks'; import { FormFactorSwitcher } from '../mobile/FormFactorSwitcher'; import { isThemeId, nextTheme, setTheme as applyTheme } from '../themes'; @@ -142,6 +143,12 @@ export function KioskApp() { const [kioskLocked, setKioskLocked] = useState(() => /[?&]lock=1/.test(location.search)); const [isFullscreen, setIsFullscreen] = useState(false); const [modal, setModal] = useState(null); + // "Browse the Agent Store" in the agent menu: open the Agent Lab, which switches itself to the Store + useEffect(() => { + const on = () => setModal('agents'); + window.addEventListener(OPEN_AGENT_STORE, on); + return () => window.removeEventListener(OPEN_AGENT_STORE, on); + }, []); const [settingsTab, setSettingsTab] = useState(() => { if (typeof window !== 'undefined') { const t = new URLSearchParams(window.location.search).get('tab'); diff --git a/ui/src/mobile/MobileAgentPicker.tsx b/ui/src/mobile/MobileAgentPicker.tsx index 9e1e561..4b5e066 100644 --- a/ui/src/mobile/MobileAgentPicker.tsx +++ b/ui/src/mobile/MobileAgentPicker.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import clsx from 'clsx'; -import { Check, X, Sparkles, Cloud, Search } from 'lucide-react'; +import { Check, X, Sparkles, Cloud, Search, Store } from 'lucide-react'; import type { Agent } from '../types'; import { AgentAvatar } from '../components/common'; -import { matchesAgent, recordAgentPick } from '../agentPicks'; +import { matchesAgent, openAgentStore, recordAgentPick } from '../agentPicks'; import { t } from '../i18n'; interface Props { @@ -105,6 +105,14 @@ export function MobileAgentPicker({ open, onClose, agents, activeId, onSelect }: ); })}
+ +
); diff --git a/ui/src/mobile/MobileApp.tsx b/ui/src/mobile/MobileApp.tsx index aaa3680..1bbbb65 100644 --- a/ui/src/mobile/MobileApp.tsx +++ b/ui/src/mobile/MobileApp.tsx @@ -46,6 +46,7 @@ import { Secretary } from '../components/Secretary'; import { MapsApp } from '../maps/MapsApp'; import { RadioApp } from '../radio/RadioApp'; import { AgentLab } from '../components/AgentLab'; +import { OPEN_AGENT_STORE } from '../agentPicks'; import { isThemeId, nextTheme, setTheme as applyTheme } from '../themes'; import { isCell, type GuestPanel } from '../cell'; import { GuestPanelHost } from '../components/CellGuest'; @@ -198,6 +199,12 @@ export function MobileApp() { const [doc, setDoc] = useState(null); useEffect(() => onDocView(setDoc), []); const [modal, setModal] = useState(null); + // "Browse the Agent Store" in the agent menu: open the Agent Lab, which switches itself to the Store + useEffect(() => { + const on = () => setModal('agents'); + window.addEventListener(OPEN_AGENT_STORE, on); + return () => window.removeEventListener(OPEN_AGENT_STORE, on); + }, []); const [guestPanel, setGuestPanel] = useState(null); // a guest's cell: the gateway's panels (CellGuest.tsx) const [settingsTab, setSettingsTab] = useState('general'); const [labAgent, setLabAgent] = useState(); @@ -1172,6 +1179,10 @@ export function MobileApp() { loadAgents(); if (id) setLabAgent(id); }} + onChat={id => { + const a = agents.find(x => x.id === id); + if (a) selectAgent(a); + }} />