diff --git a/workspace/frontend/components/layout/nav-agents.tsx b/workspace/frontend/components/layout/nav-agents.tsx index feaf7a64e..57cf77178 100644 --- a/workspace/frontend/components/layout/nav-agents.tsx +++ b/workspace/frontend/components/layout/nav-agents.tsx @@ -15,7 +15,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'; import { AgentRosterHints } from '@/components/agents/agent-roster-hints'; // v1.1 M1 import { cn } from '@/lib/utils'; import { agentLabel } from '@/lib/helpers'; -import { ownedSidebarAgents } from '@/lib/sidebar-agents'; +import { sidebarAgents } from '@/lib/sidebar-agents'; import { agentAvailability, availabilityDotClass, availabilityLabel } from '@/lib/collab'; // v1.1 M3 import { useWorkspace } from '@/lib/workspace-context'; import { dmSessionId, myAddress } from '@/lib/dm'; @@ -37,7 +37,7 @@ export function NavAgents({ onNavigate }: { onNavigate?: () => void }) { const t = useT(); const [open, setOpen] = useState(true); - const recentAgents = useMemo(() => ownedSidebarAgents(agents, currentUser.id), [agents, currentUser.id]); + const recentAgents = useMemo(() => sidebarAgents(agents, currentUser.id), [agents, currentUser.id]); const onlineCount = recentAgents.filter((a) => a.status === 'online').length; if (recentAgents.length === 0 && onlineUsers.length === 0) return null; diff --git a/workspace/frontend/components/layout/nav-rail.test.ts b/workspace/frontend/components/layout/nav-rail.test.ts index 6545f16d3..1b068b98e 100644 --- a/workspace/frontend/components/layout/nav-rail.test.ts +++ b/workspace/frontend/components/layout/nav-rail.test.ts @@ -9,7 +9,7 @@ vi.mock('./layout-context', () => ({ useLayout: () => ({ viewMode: 'threads', openView: state.openView, isRailExpanded: true, railDragWidth: null, }), RAIL_WIDTH_COLLAPSED: 48, RAIL_WIDTH_EXPANDED: 180 })); vi.mock('@/lib/workspace-context', () => ({ useWorkspace: () => ({ - unreadThreadsTotal: state.unreadThreadsTotal, workspace: { name: 'Test' }, agents: [{ agentName: 'shared', ownerEmail: 'maya@example.com', status: 'online' }], + unreadThreadsTotal: state.unreadThreadsTotal, workspace: { name: 'Test' }, agents: [{ agentName: 'shared', ownerEmail: 'maya@example.com', status: 'online', canStartThread: true }], currentUser: { id: 'sam@example.com' }, tasks: [], onlineUsers: [], pendingApprovalsByAgent: {}, unreadDmCounts: {}, }) })); vi.mock('@/lib/i18n', () => ({ useT: () => (key: string) => key })); @@ -32,13 +32,14 @@ vi.mock('@/components/campaign/campaign-sidebar-card', () => ({ CampaignSidebarC vi.stubGlobal('React', React); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); -it('shows workspace navigation to a member with no owned agents without adding shared agents to My agents', async () => { +it('shows shared agents alongside workspace navigation for a member with no owned agents', async () => { const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render(React.createElement(NavRail))); for (const label of ['views.files', 'views.knowledge', 'views.issues', 'views.inbox', 'collab.viewAgents']) expect(host.textContent).toContain(label); - expect(host.textContent).not.toContain('shared'); + expect(host.textContent).toContain('shared'); + expect(host.textContent).toContain('nav.agentsWithCount'); expect(host.textContent).not.toContain('views.tasks'); const services = [...host.querySelectorAll('button')].find(b => b.textContent === 'collab.viewAgents')!; await act(async () => services.click()); diff --git a/workspace/frontend/components/layout/nav-rail.tsx b/workspace/frontend/components/layout/nav-rail.tsx index 931892fca..43b9d834c 100644 --- a/workspace/frontend/components/layout/nav-rail.tsx +++ b/workspace/frontend/components/layout/nav-rail.tsx @@ -26,7 +26,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'; import { AgentRosterHints } from '@/components/agents/agent-roster-hints'; // v1.1 M1 import { cn } from '@/lib/utils'; import { agentLabel } from '@/lib/helpers'; -import { ownedSidebarAgents } from '@/lib/sidebar-agents'; +import { sidebarAgents } from '@/lib/sidebar-agents'; import { agentPresence } from '@/lib/approvals'; import { useWorkspace } from '@/lib/workspace-context'; import { dmSessionId, myAddress } from '@/lib/dm'; @@ -204,7 +204,7 @@ export function NavRail() { const t = useT(); const [agentsOpen, setAgentsOpen] = React.useState(true); - const recentAgents = ownedSidebarAgents(agents, currentUser.id); + const recentAgents = sidebarAgents(agents, currentUser.id); // Yumi (built-in) still appears in the roster, but does not satisfy the // "connect your first agent" call to action. const hasAgents = recentAgents.filter((a) => !a.builtin).length > 0; diff --git a/workspace/frontend/lib/i18n/messages/en-US.ts b/workspace/frontend/lib/i18n/messages/en-US.ts index 51859e05b..d4ee9c85e 100644 --- a/workspace/frontend/lib/i18n/messages/en-US.ts +++ b/workspace/frontend/lib/i18n/messages/en-US.ts @@ -211,7 +211,7 @@ export const messages = { noMembersFound: 'No people found', thisComputer: 'This Computer', collaboration: 'Collaboration', - agentsWithCount: 'My agents ({online}/{total})', + agentsWithCount: 'Agents ({online}/{total})', onlineWithCount: 'Online ({count})', you: '{name} (you)', workspaceFallback: 'Workspace', diff --git a/workspace/frontend/lib/i18n/messages/zh-CN.ts b/workspace/frontend/lib/i18n/messages/zh-CN.ts index 88bc1dd52..749c35bf2 100644 --- a/workspace/frontend/lib/i18n/messages/zh-CN.ts +++ b/workspace/frontend/lib/i18n/messages/zh-CN.ts @@ -204,7 +204,7 @@ export const messages: Messages = { noMembersFound: '未找到成员', thisComputer: '此电脑', collaboration: '协作', - agentsWithCount: '我的智能体({online}/{total})', + agentsWithCount: '智能体({online}/{total})', onlineWithCount: '在线({count})', you: '{name}(你)', workspaceFallback: '工作区', diff --git a/workspace/frontend/lib/sidebar-agents.test.ts b/workspace/frontend/lib/sidebar-agents.test.ts index 990f2ed18..6ed28c146 100644 --- a/workspace/frontend/lib/sidebar-agents.test.ts +++ b/workspace/frontend/lib/sidebar-agents.test.ts @@ -1,12 +1,29 @@ import { expect, it } from 'vitest'; -import { ownedSidebarAgents } from './sidebar-agents'; +import { sidebarAgents } from './sidebar-agents'; import type { WorkspaceAgent } from './types'; -it('shows only owned agents including offline ones, not shared or unclaimed agents', () => { + +it('includes owned and directly usable shared agents, including offline agents', () => { const agents = [ { agentName: 'mine', ownerEmail: ' ME@EXAMPLE.COM ', status: 'offline' }, - { agentName: 'shared', ownerEmail: 'other@example.com', status: 'online', visibility: 'workspace' }, - { agentName: 'unclaimed', ownerEmail: null, status: 'online' }, + { agentName: 'shared-person', ownerEmail: 'other@example.com', status: 'online', canStartThread: true }, + { agentName: 'shared-group', ownerEmail: 'other@example.com', status: 'offline', canStartThread: true }, + { agentName: 'shared-everyone', ownerEmail: null, status: 'online', canStartThread: true }, + { agentName: 'private', ownerEmail: 'other@example.com', status: 'online', canStartThread: false }, + { agentName: 'thread-only', ownerEmail: 'other@example.com', status: 'online', isShared: true, canStartThread: false }, + { agentName: 'legacy-team-flag', ownerEmail: null, status: 'online', visibility: 'team' }, + { agentName: 'removed', ownerEmail: 'me@example.com', status: 'removed', canStartThread: true }, ] as WorkspaceAgent[]; - expect(ownedSidebarAgents(agents, 'me@example.com').map((a) => a.agentName)).toEqual(['mine']); - expect(ownedSidebarAgents(agents, '')).toEqual([]); + expect(sidebarAgents(agents, 'me@example.com').map((a) => a.agentName)) + .toEqual(['mine', 'shared-person', 'shared-group', 'shared-everyone']); + expect(sidebarAgents(agents, '')).toEqual([]); +}); + +it('removes a shared agent when its access is revoked without removing owned agents', () => { + const agents = [ + { agentName: 'mine', ownerEmail: 'me@example.com', status: 'online', canStartThread: true }, + { agentName: 'shared', ownerEmail: 'other@example.com', status: 'online', canStartThread: true }, + ] as WorkspaceAgent[]; + expect(sidebarAgents(agents, 'me@example.com')).toHaveLength(2); + expect(sidebarAgents(agents.map((agent) => ({ ...agent, canStartThread: false })), 'me@example.com') + .map((agent) => agent.agentName)).toEqual(['mine']); }); diff --git a/workspace/frontend/lib/sidebar-agents.ts b/workspace/frontend/lib/sidebar-agents.ts index 0941f3d1d..51fffa337 100644 --- a/workspace/frontend/lib/sidebar-agents.ts +++ b/workspace/frontend/lib/sidebar-agents.ts @@ -1,7 +1,10 @@ import type { WorkspaceAgent } from './types'; -/** The personal roster never includes other people's or unclaimed agents. */ -export function ownedSidebarAgents(agents: WorkspaceAgent[], email: string) { - const owner = email.trim().toLowerCase(); - return owner ? agents.filter((agent) => agent.ownerEmail?.trim().toLowerCase() === owner && agent.status !== 'removed') : []; +/** Owned agents and agents the server permits this viewer to contact directly. */ +export function sidebarAgents(agents: WorkspaceAgent[], email: string) { + const viewer = email.trim().toLowerCase(); + if (!viewer) return []; + return agents.filter((agent) => agent.status !== 'removed' && ( + agent.ownerEmail?.trim().toLowerCase() === viewer || agent.canStartThread === true + )); }