diff --git a/app/src/components/intelligence/IntelligenceTeamsTab.test.tsx b/app/src/components/intelligence/IntelligenceTeamsTab.test.tsx new file mode 100644 index 000000000..e92293980 --- /dev/null +++ b/app/src/components/intelligence/IntelligenceTeamsTab.test.tsx @@ -0,0 +1,191 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type AgentTeam, agentTeamApi, type TeamView } from '../../services/api/agentTeamApi'; +import IntelligenceTeamsTab from './IntelligenceTeamsTab'; + +vi.mock('../../services/api/agentTeamApi', () => ({ + agentTeamApi: { list: vi.fn(), get: vi.fn(), listMessages: vi.fn() }, +})); +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +const mockList = vi.mocked(agentTeamApi.list); +const mockGet = vi.mocked(agentTeamApi.get); +const mockMessages = vi.mocked(agentTeamApi.listMessages); + +function team(id: string, summary: string): AgentTeam { + return { + id, + leadAgentId: `lead-${id}`, + status: 'active', + summary, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; +} + +function view(t: AgentTeam): TeamView { + return { + team: t, + members: [ + { + id: 'm1', + teamId: t.id, + name: 'planner', + memberStatus: 'active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + tasks: [ + { + id: 'task-1', + teamId: t.id, + title: 'Audit flow', + status: 'todo', + dependsOn: [], + gateStatus: 'pending', + evidence: [], + orderIndex: 0, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockMessages.mockResolvedValue([]); +}); + +describe('IntelligenceTeamsTab', () => { + it('shows the empty state when there are no teams', async () => { + mockList.mockResolvedValue([]); + render(); + expect(await screen.findByText('intelligence.teams.empty')).toBeInTheDocument(); + expect(screen.getByText('intelligence.teams.emptyHint')).toBeInTheDocument(); + }); + + it('requests active teams only (closed teams are not surfaced)', async () => { + mockList.mockResolvedValue([]); + render(); + await screen.findByText('intelligence.teams.empty'); + expect(mockList).toHaveBeenCalledWith({ status: 'active' }); + }); + + it('surfaces a load error', async () => { + mockList.mockRejectedValue(new Error('core down')); + render(); + expect(await screen.findByText(/intelligence.teams.failedToLoad/)).toBeInTheDocument(); + expect(screen.getByText(/core down/)).toBeInTheDocument(); + }); + + it('recovers from a load error via the retry button', async () => { + // First list rejects (error state), the retry succeeds. Counter-based impl + // (not mock*Once) keeps this independent of any implementation inherited + // from a prior test under the clearMocks-only config. Two teams on retry so + // the assertion lands on the stable list view (no auto-select detail race). + let calls = 0; + mockList.mockImplementation(() => { + calls += 1; + return calls === 1 + ? Promise.reject(new Error('core down')) + : Promise.resolve([team('team-1', 'Alpha'), team('team-2', 'Beta')]); + }); + render(); + + await screen.findByText(/core down/); + fireEvent.click(screen.getByText('intelligence.teams.refresh')); + expect(await screen.findByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('ignores a stale detail response after the selection changes', async () => { + const a = team('team-1', 'Team A'); + const b = team('team-2', 'Team B'); + mockList.mockResolvedValue([a, b]); + + let resolveA: (v: TeamView) => void = () => {}; + const slowA = new Promise(resolve => { + resolveA = resolve; + }); + mockGet.mockImplementation((id: string) => + id === 'team-1' ? slowA : Promise.resolve(view(b)) + ); + + render(); + await screen.findByText('Team A'); // list view (no auto-select with >1 team) + + fireEvent.click(screen.getByText('Team A')); // select A — detail fetch hangs + fireEvent.click(screen.getByText('Team B')); // switch to B before A resolves + await screen.findByText('Audit flow'); // B's detail rendered + + resolveA(view(a)); // late A response must NOT overwrite B + await waitFor(() => expect(screen.getByText('Team B')).toBeInTheDocument()); + expect(screen.queryByText('Team A')).not.toBeInTheDocument(); + }); + + it('recovers from a detail-fetch error via the retry button', async () => { + // Regression for the stuck-error bug: one team auto-selects, its detail + // fetch fails, then the retry succeeds. `refresh` must clear `error` first + // or the error branch short-circuits the render and the recovered board is + // never shown. Counter-based impl, not mock*Once (clearMocks-only config). + mockList.mockResolvedValue([team('team-1', 'Ship onboarding')]); + let calls = 0; + mockGet.mockImplementation(() => { + calls += 1; + return calls === 1 + ? Promise.reject(new Error('detail timeout')) + : Promise.resolve(view(team('team-1', 'Ship onboarding'))); + }); + render(); + + await screen.findByText(/detail timeout/); + fireEvent.click(screen.getByText('intelligence.teams.refresh')); + expect(await screen.findByText('Audit flow')).toBeInTheDocument(); + expect(screen.queryByText(/detail timeout/)).not.toBeInTheDocument(); + }); + + it('auto-selects and renders the board when there is exactly one team', async () => { + const t = team('team-1', 'Ship onboarding'); + mockList.mockResolvedValue([t]); + mockGet.mockResolvedValue(view(t)); + render(); + + // Await a detail-only element (the task title) so the assertion can't + // resolve early on the brief single-team list view, then check the rest. + expect(await screen.findByText('Audit flow')).toBeInTheDocument(); + expect(screen.getByText('Ship onboarding')).toBeInTheDocument(); + expect(screen.getByText('intelligence.teams.column.todo')).toBeInTheDocument(); + expect(screen.getByText('intelligence.teams.activity.title')).toBeInTheDocument(); + expect(mockGet).toHaveBeenCalledWith('team-1'); + }); + + it('lists multiple teams and opens one on click', async () => { + const a = team('team-1', 'Ship onboarding'); + const b = team('team-2', 'Fix billing'); + mockList.mockResolvedValue([a, b]); + mockGet.mockResolvedValue(view(a)); + render(); + + // List view first (no auto-select with >1 team). + expect(await screen.findByText('Ship onboarding')).toBeInTheDocument(); + expect(screen.getByText('Fix billing')).toBeInTheDocument(); + expect(mockGet).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('Ship onboarding')); + await waitFor(() => expect(mockGet).toHaveBeenCalledWith('team-1')); + expect(await screen.findByText('Audit flow')).toBeInTheDocument(); + }); + + it('refreshes the list via the refresh button', async () => { + mockList.mockResolvedValue([team('team-1', 'A'), team('team-2', 'B')]); + render(); + await screen.findByText('A'); + expect(mockList).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('intelligence.teams.refresh')); + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/app/src/components/intelligence/IntelligenceTeamsTab.tsx b/app/src/components/intelligence/IntelligenceTeamsTab.tsx new file mode 100644 index 000000000..db1671304 --- /dev/null +++ b/app/src/components/intelligence/IntelligenceTeamsTab.tsx @@ -0,0 +1,290 @@ +/** + * IntelligenceTeamsTab — the agent-team coordination surface (#3374 PR2). + * + * Read-only window onto the durable team ledger (PR1, #3546). It lists the + * teams an agent has spawned, and — for a selected team — renders treatment "A": + * a {@link TeamHeader} identity strip, the {@link TeamTaskBoard} of owned tasks, + * and an always-visible {@link TeamActivityRail} of teammate messages. The + * board/rail sit in a grid that collapses the rail under the board on narrow + * widths. + * + * Most users will see the EMPTY state first: a team only exists once an agent + * spawns coordinated sub-agents, so "no teams yet" is the common first view and + * is treated as a first-class surface rather than a blank panel. + * + * There is no socket event for team mutations yet (PR1 was storage + RPC only), + * so freshness comes from a mount fetch + a light poll of the *selected* team + * while it is still active + a manual refresh. Polling is scoped to the open + * team (not the whole list) to bound cost, and stops when the team is closed. + */ +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { LuArrowLeft, LuRefreshCw, LuUsers } from 'react-icons/lu'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + type AgentTeam, + agentTeamApi, + type TeamMessage, + type TeamView, +} from '../../services/api/agentTeamApi'; +import { TeamActivityRail } from './TeamActivityRail'; +import { TeamHeader } from './TeamHeader'; +import { TeamTaskBoard } from './TeamTaskBoard'; + +const log = debug('intelligence:teams'); + +/** Poll cadence for the open team while it is still active. */ +const POLL_INTERVAL_MS = 5000; +/** Cap on messages fetched for the activity rail. */ +const MESSAGE_LIMIT = 200; + +export default function IntelligenceTeamsTab() { + const { t } = useT(); + + const [teams, setTeams] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [view, setView] = useState(null); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const mountedRef = useRef(true); + // Mirrors `view` for reads inside the poll interval without making the poll + // effect depend on `view` (which would rebuild the interval every tick). + const viewRef = useRef(null); + // Mirrors `selectedId` so an in-flight `fetchDetail` can drop its result if + // the selection changed (or cleared) while it was awaiting — a slower + // detail fetch for an old team must not overwrite a newer selection. + const selectedIdRef = useRef(null); + + useEffect(() => { + selectedIdRef.current = selectedId; + }, [selectedId]); + + const fetchTeams = useCallback(async () => { + log('fetchTeams: entry'); + setError(null); + try { + // Active teams only — `agent_team_list` returns closed teams too, and a + // coordination board surfacing archived teams as primary entries is + // noise. Closed-team history is a separate (future) surface. + const rows = await agentTeamApi.list({ status: 'active' }); + if (!mountedRef.current) return; + setTeams(rows); + log('fetchTeams: done teams=%d', rows.length); + // Auto-select when there is exactly one team — the common single-team + // case lands straight on the board instead of a one-row list. + if (rows.length === 1) setSelectedId(prev => prev ?? rows[0].id); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('fetchTeams: error %s', msg); + if (mountedRef.current) setError(msg); + } finally { + if (mountedRef.current) setLoading(false); + } + }, []); + + const fetchDetail = useCallback(async (teamId: string) => { + log('fetchDetail: entry team=%s', teamId); + const [nextView, nextMessages] = await Promise.all([ + agentTeamApi.get(teamId), + agentTeamApi.listMessages(teamId, MESSAGE_LIMIT), + ]); + // Drop a stale response: unmounted, or the selection moved on while we + // were awaiting (guards the slow-fetch-overwrites-newer-team race). + if (!mountedRef.current || selectedIdRef.current !== teamId) return; + setView(nextView); + viewRef.current = nextView; + setMessages(nextMessages); + log('fetchDetail: done found=%o messages=%d', nextView != null, nextMessages.length); + }, []); + + // Deselect a team: clears the detail synchronously from the event handler + // (not an effect) so going "back to list" never flashes a stale board. + const deselect = useCallback(() => { + setSelectedId(null); + setView(null); + viewRef.current = null; + setMessages([]); + }, []); + + // Mount fetch of the team list (mirrors IntelligenceAgentWorkTab's 0ms + // setTimeout so the first paint shows the loading state). + useEffect(() => { + mountedRef.current = true; + const handle = window.setTimeout(() => void fetchTeams(), 0); + return () => { + window.clearTimeout(handle); + mountedRef.current = false; + }; + }, [fetchTeams]); + + // Load + poll the selected team's detail while it is active. Keyed on + // `selectedId` only — the closed-team check reads `viewRef` so the interval + // isn't torn down and rebuilt on every poll response. + useEffect(() => { + if (!selectedId) return; + void fetchDetail(selectedId).catch(err => { + if (mountedRef.current) setError(err instanceof Error ? err.message : String(err)); + }); + const interval = window.setInterval(() => { + // Stop polling once the team is closed; the board won't change further. + if (viewRef.current?.team.status === 'closed') return; + void fetchDetail(selectedId).catch(() => { + /* transient poll failure — keep the last good view */ + }); + }, POLL_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [selectedId, fetchDetail]); + + const refresh = useCallback(async () => { + // Clear any prior error before retrying. Without this a successful retry + // after a `fetchDetail` failure would refresh `view`/`messages` but leave + // `error` set, so the error-branch render short-circuits and the user is + // stuck on the banner forever. `fetchTeams` self-clears; `fetchDetail` + // deliberately doesn't (so silent polls don't wipe a visible error), so the + // clear belongs here on the explicit user-driven retry. + setError(null); + setRefreshing(true); + try { + if (selectedId) await fetchDetail(selectedId); + else await fetchTeams(); + } catch (err) { + if (mountedRef.current) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (mountedRef.current) setRefreshing(false); + } + }, [selectedId, fetchDetail, fetchTeams]); + + if (loading) { + return ( +
+
+ {t('intelligence.teams.loading')} +
+ ); + } + + if (error) { + return ( +
+
+ {t('intelligence.teams.failedToLoad')}: {error} +
+ void refresh()} t={t} /> +
+ ); + } + + if (teams.length === 0) { + return ( +
+

+ {t('intelligence.teams.subtitle')} +

+
+ +

+ {t('intelligence.teams.empty')} +

+

+ {t('intelligence.teams.emptyHint')} +

+
+
+ ); + } + + // Detail view — selected team's board + activity rail. + if (selectedId && view) { + return ( +
+
+ {teams.length > 1 ? ( + + ) : ( + + )} + void refresh()} t={t} /> +
+ + + +
+ + +
+
+ ); + } + + // List view — pick a team. + return ( +
+
+

+ {t('intelligence.teams.subtitle')} +

+ void refresh()} t={t} /> +
+
    + {teams.map(team => ( +
  • + +
  • + ))} +
+
+ ); +} + +function RefreshButton({ + refreshing, + onClick, + t, +}: { + refreshing: boolean; + onClick: () => void; + t: (key: string) => string; +}) { + return ( + + ); +} diff --git a/app/src/components/intelligence/TeamActivityRail.test.tsx b/app/src/components/intelligence/TeamActivityRail.test.tsx new file mode 100644 index 000000000..3bbb995ea --- /dev/null +++ b/app/src/components/intelligence/TeamActivityRail.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentTeamMember, TeamMessage } from '../../services/api/agentTeamApi'; +import { TeamActivityRail } from './TeamActivityRail'; + +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +function member(id: string, name: string): AgentTeamMember { + return { + id, + teamId: 'team-1', + name, + memberStatus: 'active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; +} + +function message(seq: number, from: string, to: string | null, content: string): TeamMessage { + return { + runId: 'team-1', + sequence: seq, + eventType: 'team_message', + payload: { from, to, content, visibility: 'team' }, + timestamp: '2026-01-01T00:00:00Z', + }; +} + +const members = [member('m1', 'planner'), member('m2', 'builder')]; + +describe('TeamActivityRail', () => { + it('shows the empty state when there are no messages', () => { + render(); + expect(screen.getByText('intelligence.teams.activity.empty')).toBeInTheDocument(); + }); + + it('renders sender name and message content', () => { + render( + + ); + expect(screen.getByText('planner')).toBeInTheDocument(); + expect(screen.getByText('split the build')).toBeInTheDocument(); + expect(screen.getByText('builder', { exact: false })).toBeInTheDocument(); + }); + + it('labels a broadcast (null recipient) as the team', () => { + render(); + expect( + screen.getByText('intelligence.teams.activity.toTeam', { exact: false }) + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/TeamActivityRail.tsx b/app/src/components/intelligence/TeamActivityRail.tsx new file mode 100644 index 000000000..a0f8b54a1 --- /dev/null +++ b/app/src/components/intelligence/TeamActivityRail.tsx @@ -0,0 +1,77 @@ +/** + * TeamActivityRail — the live teammate-message timeline for an agent team + * (#3374 PR2). + * + * This is the surface's differentiator: a board of owned tasks is commodity, + * but seeing the agents *talk to each other while they work* is the thing that + * makes multi-agent coordination legible. Each entry is a `team_message` run + * event resolved to `from → to` (member names, with the member's deterministic + * colour) plus the message body. A `null` recipient means the message was sent + * to the whole team. + * + * The rail renders inside a grid cell that collapses *under* the board on + * narrow widths (handled by the parent tab's responsive grid), so it is always + * visible on a wide Intelligence pane and stacks gracefully when cramped. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { AgentTeamMember, TeamMessage } from '../../services/api/agentTeamApi'; +import { memberColor } from './memberColors'; + +interface TeamActivityRailProps { + messages: TeamMessage[]; + members: AgentTeamMember[]; +} + +export function TeamActivityRail({ messages, members }: TeamActivityRailProps) { + const { t } = useT(); + const memberById = new Map(members.map(m => [m.id, m])); + + const nameFor = (id: string | null): string => { + if (!id) return t('intelligence.teams.activity.toTeam'); + return memberById.get(id)?.name ?? id; + }; + + return ( + + ); +} diff --git a/app/src/components/intelligence/TeamHeader.tsx b/app/src/components/intelligence/TeamHeader.tsx new file mode 100644 index 000000000..e435b9f7a --- /dev/null +++ b/app/src/components/intelligence/TeamHeader.tsx @@ -0,0 +1,83 @@ +/** + * TeamHeader — the identity strip above a team's task board (#3374 PR2). + * + * Shows the team's summary (falling back to its lead agent id), the lead, a + * task/member count line, and a compact roster of member chips. Each chip + * carries the member's deterministic colour (so a member reads the same on the + * header, the board border and the activity rail) and a status dot + * (active / pending / idle / stopped). For the small teams this surface targets + * (≈2–5 members) a header strip is the right density — a full sidebar would be + * heavy furniture for three chips. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { + AgentTeam, + AgentTeamMember, + AgentTeamMemberStatus, +} from '../../services/api/agentTeamApi'; +import { memberColor } from './memberColors'; + +/** Status dot colour per member lifecycle state. */ +const MEMBER_STATUS_DOT: Record = { + active: 'bg-sage-500', + pending: 'bg-amber-500', + idle: 'bg-stone-400 dark:bg-neutral-500', + stopped: 'bg-coral-500', +}; + +const MEMBER_STATUS_KEY: Record = { + active: 'intelligence.teams.member.active', + pending: 'intelligence.teams.member.pending', + idle: 'intelligence.teams.member.idle', + stopped: 'intelligence.teams.member.stopped', +}; + +interface TeamHeaderProps { + team: AgentTeam; + members: AgentTeamMember[]; + taskCount: number; +} + +export function TeamHeader({ team, members, taskCount }: TeamHeaderProps) { + const { t } = useT(); + const title = team.summary?.trim() || team.leadAgentId; + + return ( +
+
+
+
+ {title} +
+
+ {t('intelligence.teams.header.lead')}{' '} + {team.leadAgentId} + {' · '} + {t('intelligence.teams.header.taskCount').replace('{count}', String(taskCount))} + {' · '} + {t('intelligence.teams.header.memberCount').replace('{count}', String(members.length))} +
+
+ +
+ {members.map(member => ( + + + {member.name.charAt(0).toUpperCase()} + + {member.name} + + + ))} +
+
+
+ ); +} diff --git a/app/src/components/intelligence/TeamTaskBoard.test.tsx b/app/src/components/intelligence/TeamTaskBoard.test.tsx new file mode 100644 index 000000000..3b42b29a7 --- /dev/null +++ b/app/src/components/intelligence/TeamTaskBoard.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentTeamMember, AgentTeamTask } from '../../services/api/agentTeamApi'; +import { TeamTaskBoard, unmetDepCount } from './TeamTaskBoard'; + +// i18n → echo the key so assertions can target stable strings. +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +function member(id: string, name: string): AgentTeamMember { + return { + id, + teamId: 'team-1', + name, + memberStatus: 'active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; +} + +function task(overrides: Partial): AgentTeamTask { + return { + id: 'task-x', + teamId: 'team-1', + title: 'Untitled', + status: 'todo', + dependsOn: [], + gateStatus: 'pending', + evidence: [], + orderIndex: 0, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('unmetDepCount', () => { + it('counts deps whose task is not done', () => { + const byId = new Map([ + ['a', task({ id: 'a', status: 'done' })], + ['b', task({ id: 'b', status: 'in_progress' })], + ]); + const t = task({ id: 't', dependsOn: ['a', 'b'] }); + expect(unmetDepCount(t, byId)).toBe(1); // a done, b not + }); + + it('ignores unknown dep ids', () => { + const byId = new Map(); + expect(unmetDepCount(task({ dependsOn: ['ghost'] }), byId)).toBe(0); + }); +}); + +describe('TeamTaskBoard', () => { + const members = [member('m1', 'planner'), member('m2', 'builder')]; + + it('renders all five column labels', () => { + render(); + for (const key of [ + 'intelligence.teams.column.todo', + 'intelligence.teams.column.ready', + 'intelligence.teams.column.inProgress', + 'intelligence.teams.column.blocked', + 'intelligence.teams.column.done', + ]) { + expect(screen.getByText(key)).toBeInTheDocument(); + } + }); + + it('shows the owner name and hides the claimed-by line when claimer is the owner', () => { + render( + + ); + expect(screen.getByText('planner')).toBeInTheDocument(); + expect(screen.queryByText('intelligence.teams.pickedUpBy')).not.toBeInTheDocument(); + }); + + it('shows the claimed-by line when a non-owner picked the task up', () => { + render( + + ); + expect(screen.getByText('intelligence.teams.pickedUpBy')).toBeInTheDocument(); + }); + + it('marks an unowned task as unclaimed', () => { + render(); + expect(screen.getByText('intelligence.teams.unclaimed')).toBeInTheDocument(); + }); + + it('renders a dependency lock with the unmet count', () => { + const tasks = [ + task({ id: 'dep', status: 'in_progress' }), + task({ id: 't1', dependsOn: ['dep'] }), + ]; + render(); + // lock badge shows the unmet count "1" + expect(screen.getByTitle('intelligence.teams.depLockTitle')).toBeInTheDocument(); + }); + + it('maps known gate states and falls back to the raw label for unknown', () => { + const { rerender } = render( + + ); + expect(screen.getByText('intelligence.teams.gate.passed')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('intelligence.teams.gate.label')).toBeInTheDocument(); + }); + + it('places a task in the column matching its status', () => { + render( + + ); + expect(screen.getByText('Blocked one')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/TeamTaskBoard.tsx b/app/src/components/intelligence/TeamTaskBoard.tsx new file mode 100644 index 000000000..e0f24f871 --- /dev/null +++ b/app/src/components/intelligence/TeamTaskBoard.tsx @@ -0,0 +1,242 @@ +/** + * TeamTaskBoard — the five-column kanban for a single agent team's tasks + * (#3374 PR2). + * + * A sibling of the conversations `TaskKanbanBoard`, NOT a reuse of it: team + * tasks are `AgentTeamTask` (owner / claimed-by / dependency graph / quality + * gate) rather than the generic `TaskBoardCard`, so folding them into the + * shared component would leak team concepts into the conversations board and + * risk regressing it. This board deliberately speaks the same visual language — + * the same five-column shell, the same amber/coral column accents, the same + * house tokens — so it reads as the same product while owning the team-specific + * affordances: + * + * - owner colour left-border + owner chip (deterministic per-member colour) + * - "picked up by …" line when the claimer differs from the owner + * - dependency lock badge showing the count of unmet (non-`done`) deps + * - quality-gate badge (pending / passed / failed / unknown) + * + * Read-only: tasks are driven by agents over the `agent_team_*` RPCs. There is + * no drag-and-drop or status mutation here (that is the agents' job). + */ +import { LuCircleCheck, LuLock, LuShieldCheck } from 'react-icons/lu'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { + AgentTeamMember, + AgentTeamTask, + AgentTeamTaskStatus, +} from '../../services/api/agentTeamApi'; +import { memberColor, memberTint } from './memberColors'; + +/** The five columns, in display order. Mirrors the team task lifecycle. */ +const COLUMN_DEFS: { status: AgentTeamTaskStatus; labelKey: string }[] = [ + { status: 'todo', labelKey: 'intelligence.teams.column.todo' }, + { status: 'ready', labelKey: 'intelligence.teams.column.ready' }, + { status: 'in_progress', labelKey: 'intelligence.teams.column.inProgress' }, + { status: 'blocked', labelKey: 'intelligence.teams.column.blocked' }, + { status: 'done', labelKey: 'intelligence.teams.column.done' }, +]; + +/** Per-column accent — mirrors the conversations board's amber/coral tints. */ +const COLUMN_ACCENT: Record = { + todo: '', + ready: '', + in_progress: '', + blocked: + 'border-l-2 border-l-coral-400 bg-coral-50/60 dark:bg-coral-500/5 dark:border-l-coral-500/60', + done: '', +}; + +/** Known gate states → badge styling. Unknown strings fall back to neutral. */ +const GATE_STYLE: Record = { + pending: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300', + passed: 'bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300', + approved: 'bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300', + failed: 'bg-coral-50 text-coral-700 dark:bg-coral-500/10 dark:text-coral-300', + rejected: 'bg-coral-50 text-coral-700 dark:bg-coral-500/10 dark:text-coral-300', +}; + +const GATE_LABEL_KEY: Record = { + pending: 'intelligence.teams.gate.pending', + passed: 'intelligence.teams.gate.passed', + approved: 'intelligence.teams.gate.passed', + failed: 'intelligence.teams.gate.failed', + rejected: 'intelligence.teams.gate.failed', +}; + +/** + * Count a task's unmet dependencies: dep ids that resolve to a known task whose + * status is not yet `done`. Unknown ids are ignored (the backend rejects them + * at assign time, so they shouldn't occur; guarding avoids a phantom lock). + */ +export function unmetDepCount(task: AgentTeamTask, byId: Map): number { + let unmet = 0; + for (const depId of task.dependsOn) { + const dep = byId.get(depId); + if (dep && dep.status !== 'done') unmet += 1; + } + return unmet; +} + +interface TeamTaskBoardProps { + tasks: AgentTeamTask[]; + members: AgentTeamMember[]; +} + +export function TeamTaskBoard({ tasks, members }: TeamTaskBoardProps) { + const { t } = useT(); + + const memberById = new Map(members.map(m => [m.id, m])); + const taskById = new Map(tasks.map(task => [task.id, task])); + + const cardsByStatus = COLUMN_DEFS.reduce( + (acc, column) => { + acc[column.status] = []; + return acc; + }, + {} as Record + ); + for (const task of [...tasks].sort((a, b) => a.orderIndex - b.orderIndex)) { + (cardsByStatus[task.status] ?? cardsByStatus.todo).push(task); + } + + return ( +
+ {COLUMN_DEFS.map(column => { + const cards = cardsByStatus[column.status]; + const accentClass = COLUMN_ACCENT[column.status]; + return ( +
+
+
+ {t(column.labelKey)} +
+ + {cards.length} + +
+
+ {cards.length === 0 ? ( +

+ {t('intelligence.teams.emptyColumn')} +

+ ) : ( + cards.map(task => ( + + )) + )} +
+
+ ); + })} +
+ ); +} + +function TeamTaskCard({ + task, + memberById, + unmet, +}: { + task: AgentTeamTask; + memberById: Map; + unmet: number; +}) { + const { t } = useT(); + const owner = task.ownerMemberId ? memberById.get(task.ownerMemberId) : undefined; + const claimer = task.claimedByMemberId ? memberById.get(task.claimedByMemberId) : undefined; + // Only surface the claimer line when it adds information — i.e. someone other + // than the owner picked the task up. + const showClaimer = claimer && claimer.id !== task.ownerMemberId; + + // The owner colour left-border is the at-a-glance "whose task is this" cue. + const borderStyle = owner ? { borderLeft: `3px solid ${memberColor(owner.id)}` } : undefined; + + return ( +
+

+ {task.title} +

+ + {showClaimer && ( +

+ {t('intelligence.teams.pickedUpBy').replace('{name}', claimer.name)} +

+ )} + +
+ {owner ? ( + + + {owner.name.charAt(0).toUpperCase()} + + {owner.name} + + ) : ( + + {t('intelligence.teams.unclaimed')} + + )} + + {unmet > 0 && ( + + + {unmet} + + )} + + + + {task.evidence.length > 0 && ( + + + {task.evidence.length} + + )} +
+ + {task.objective && ( +

+ {task.objective} +

+ )} +
+ ); +} + +function GateBadge({ gateStatus, gateReason }: { gateStatus: string; gateReason?: string | null }) { + const { t } = useT(); + // Defensive mapping: an unseen gate string must render as a neutral chip + // showing its raw value, never crash or vanish. + const style = + GATE_STYLE[gateStatus] ?? + 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'; + const label = GATE_LABEL_KEY[gateStatus] + ? t(GATE_LABEL_KEY[gateStatus]) + : t('intelligence.teams.gate.label').replace('{status}', gateStatus); + + return ( + + + {label} + + ); +} diff --git a/app/src/components/intelligence/memberColors.test.ts b/app/src/components/intelligence/memberColors.test.ts new file mode 100644 index 000000000..bdbb5e9a0 --- /dev/null +++ b/app/src/components/intelligence/memberColors.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { memberColor, memberTint } from './memberColors'; + +const PALETTE = ['#4A83DD', '#34C759', '#E8A728', '#EF4444', '#0EA5E9', '#9B8AFB']; + +describe('memberColor', () => { + it('is deterministic for the same id', () => { + expect(memberColor('member-abc')).toBe(memberColor('member-abc')); + }); + + it('always returns a house-palette hue', () => { + for (const id of ['a', 'bb', 'team-member-1', 'm-42', 'zzz']) { + expect(PALETTE).toContain(memberColor(id)); + } + }); + + it('falls back to the first hue for empty/undefined ids', () => { + expect(memberColor('')).toBe(PALETTE[0]); + expect(memberColor(null)).toBe(PALETTE[0]); + expect(memberColor(undefined)).toBe(PALETTE[0]); + }); + + it('spreads distinct ids across more than one hue', () => { + const colors = new Set(['m1', 'm2', 'm3', 'm4', 'm5', 'm6'].map(memberColor)); + expect(colors.size).toBeGreaterThan(1); + }); +}); + +describe('memberTint', () => { + it('appends the default alpha byte to the colour', () => { + expect(memberTint('m1')).toBe(`${memberColor('m1')}1f`); + }); + + it('honours a custom alpha suffix', () => { + expect(memberTint('m1', '33')).toBe(`${memberColor('m1')}33`); + }); +}); diff --git a/app/src/components/intelligence/memberColors.ts b/app/src/components/intelligence/memberColors.ts new file mode 100644 index 000000000..e035e5edf --- /dev/null +++ b/app/src/components/intelligence/memberColors.ts @@ -0,0 +1,57 @@ +/** + * Deterministic member → colour mapping for the agent-team surface (#3374). + * + * A team member should read as the *same* colour everywhere it appears — the + * owner left-border on a task card, the roster chip in the header, and the + * avatar in the activity rail. Tailwind can't express a per-member dynamic + * class (the class names aren't known at build time, so they'd be purged), so + * the colour is resolved to a house-palette hex here and applied via inline + * style at the call site — the same approach `memory-workspace.css` already + * uses for the ocean accent. + * + * The palette is drawn from the project's semantic tokens (ocean / sage / amber + * / coral / sky / lavender) so the board never introduces an off-brand hue. + * Assignment is stable per member id: the same id always maps to the same + * colour, and ids spread across the palette before repeating. + */ + +/** House-palette hues used to distinguish team members. Order is the cycle. */ +const MEMBER_PALETTE = [ + '#4A83DD', // ocean + '#34C759', // sage + '#E8A728', // amber + '#EF4444', // coral + '#0EA5E9', // sky (darkened for contrast on white) + '#9B8AFB', // lavender accent +] as const; + +/** Stable, order-independent hash of a member id → palette bucket. */ +function hashId(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + // Classic 31-multiplier string hash; `| 0` keeps it in 32-bit int range. + hash = (hash * 31 + id.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +/** + * Resolve a member id to a stable house-palette hex colour. + * + * @param memberId The member's durable id. Empty/undefined → first palette hue. + */ +export function memberColor(memberId: string | null | undefined): string { + if (!memberId) return MEMBER_PALETTE[0]; + return MEMBER_PALETTE[hashId(memberId) % MEMBER_PALETTE.length]; +} + +/** + * A translucent tint of a member's colour, for chip/badge backgrounds. Appends + * an alpha byte to the 6-digit hex (e.g. `#4A83DD` + `1f` → ~12% opacity). + * + * @param memberId The member's durable id. + * @param alphaHex Two-hex-digit alpha suffix. Defaults to `1f` (~12%). + */ +export function memberTint(memberId: string | null | undefined, alphaHex = '1f'): string { + return `${memberColor(memberId)}${alphaHex}`; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 505813534..0984fcd30 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -390,6 +390,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'عمل الوكيل', 'memory.tab.agentWorkDescription': 'مركز قيادة لكل تشغيل وكيل في الخلفية — مُجمّع حسب ما يحتاج إلى مُدخلاتك، وما يعمل الآن، وما اكتمل.', + 'memory.tab.teams': 'الفِرَق', + 'memory.tab.teamsDescription': + 'فِرَق وكلاء منسَّقة — مَن يملك كل مهمة، وما المحظور، وما الذي يقوله الزملاء لبعضهم البعض.', 'memory.tab.agents': 'المكتبة', 'memory.tab.agentsDescription': 'تصفّح وشغّل الوكلاء المتاحين — لكل منهم أدواته وقدراته ومجال تركيزه.', @@ -2893,6 +2896,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'عنصر فرعي لسير العمل', 'intelligence.agentWork.openThread': 'فتح المحادثة', 'intelligence.agentWork.openWorker': 'فتح العامل', + 'intelligence.teams.subtitle': 'فِرَق وكلاء منسَّقة والمهام التي يتشاركونها.', + 'intelligence.teams.loading': 'جارٍ تحميل الفِرَق…', + 'intelligence.teams.failedToLoad': 'فشل تحميل الفِرَق', + 'intelligence.teams.empty': 'لا توجد فِرَق وكلاء بعد', + 'intelligence.teams.emptyHint': + 'تظهر الفِرَق هنا عندما يُنشئ وكيل وكلاء فرعيين منسَّقين لمشاركة هدف مشترك.', + 'intelligence.teams.refresh': 'تحديث', + 'intelligence.teams.backToList': 'كل الفِرَق', + 'intelligence.teams.status.active': 'نشط', + 'intelligence.teams.status.closed': 'مغلق', + 'intelligence.teams.header.lead': 'قائد', + 'intelligence.teams.header.taskCount': '{count} مهمة', + 'intelligence.teams.header.memberCount': '{count} عضو', + 'intelligence.teams.member.active': 'نشط', + 'intelligence.teams.member.pending': 'قيد الانتظار', + 'intelligence.teams.member.idle': 'خامل', + 'intelligence.teams.member.stopped': 'متوقف', + 'intelligence.teams.column.todo': 'للتنفيذ', + 'intelligence.teams.column.ready': 'جاهز', + 'intelligence.teams.column.inProgress': 'قيد التنفيذ', + 'intelligence.teams.column.blocked': 'محظور', + 'intelligence.teams.column.done': 'مكتمل', + 'intelligence.teams.emptyColumn': 'فارغ', + 'intelligence.teams.unclaimed': 'غير مُسنَد', + 'intelligence.teams.pickedUpBy': 'تولّاه {name}', + 'intelligence.teams.depLockTitle': '{count} تبعيات غير مُستوفاة', + 'intelligence.teams.gate.pending': 'البوابة قيد الانتظار', + 'intelligence.teams.gate.passed': 'اجتاز البوابة', + 'intelligence.teams.gate.failed': 'فشل في البوابة', + 'intelligence.teams.gate.label': 'البوابة: {status}', + 'intelligence.teams.activity.title': 'نشاط الفريق', + 'intelligence.teams.activity.empty': 'لا توجد رسائل بعد', + 'intelligence.teams.activity.toTeam': 'الفريق', 'intelligence.refine.objectiveDefault': 'حوّل المهمة المصدر إلى مهمة وكيل جاهزة للتنفيذ: {title}', 'intelligence.refine.sourceLine': 'المصدر: {url}', 'intelligence.refine.sourceIntake': 'المصدر: استقبال مصدر المهام', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index ca8f68952..33be093a8 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -396,6 +396,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'এজেন্ট কাজ', 'memory.tab.agentWorkDescription': 'প্রতিটি ব্যাকগ্রাউন্ড এজেন্ট রানের জন্য একটি কমান্ড সেন্টার — আপনার ইনপুট প্রয়োজন এমন, চলমান এবং সম্পন্ন অনুযায়ী গোষ্ঠীবদ্ধ।', + 'memory.tab.teams': 'টিম', + 'memory.tab.teamsDescription': + 'সমন্বিত এজেন্ট টিম — কে প্রতিটি কাজের মালিক, কী আটকে আছে, এবং সতীর্থরা একে অপরকে কী বলছে।', 'memory.tab.agents': 'লাইব্রেরি', 'memory.tab.agentsDescription': 'আপনার উপলব্ধ এজেন্টগুলি ব্রাউজ করুন এবং চালান — প্রতিটির নিজস্ব টুল, ক্ষমতা এবং ফোকাস এলাকা রয়েছে।', @@ -2952,6 +2955,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'ওয়ার্কফ্লো চাইল্ড', 'intelligence.agentWork.openThread': 'থ্রেড খুলুন', 'intelligence.agentWork.openWorker': 'ওয়ার্কার খুলুন', + 'intelligence.teams.subtitle': 'সমন্বিত এজেন্ট টিম এবং তারা যে কাজগুলো ভাগ করে নেয়।', + 'intelligence.teams.loading': 'টিম লোড হচ্ছে…', + 'intelligence.teams.failedToLoad': 'টিম লোড করতে ব্যর্থ হয়েছে', + 'intelligence.teams.empty': 'এখনো কোনো এজেন্ট টিম নেই', + 'intelligence.teams.emptyHint': + 'যখন কোনো এজেন্ট একটি লক্ষ্য ভাগ করে নিতে সমন্বিত সাব-এজেন্ট তৈরি করে, তখন টিম এখানে দেখা যায়।', + 'intelligence.teams.refresh': 'রিফ্রেশ', + 'intelligence.teams.backToList': 'সব টিম', + 'intelligence.teams.status.active': 'সক্রিয়', + 'intelligence.teams.status.closed': 'বন্ধ', + 'intelligence.teams.header.lead': 'লিড', + 'intelligence.teams.header.taskCount': '{count} কাজ', + 'intelligence.teams.header.memberCount': '{count} সদস্য', + 'intelligence.teams.member.active': 'সক্রিয়', + 'intelligence.teams.member.pending': 'মুলতুবি', + 'intelligence.teams.member.idle': 'নিষ্ক্রিয়', + 'intelligence.teams.member.stopped': 'থেমে গেছে', + 'intelligence.teams.column.todo': 'করণীয়', + 'intelligence.teams.column.ready': 'প্রস্তুত', + 'intelligence.teams.column.inProgress': 'চলমান', + 'intelligence.teams.column.blocked': 'ব্লকড', + 'intelligence.teams.column.done': 'সম্পন্ন', + 'intelligence.teams.emptyColumn': 'খালি', + 'intelligence.teams.unclaimed': 'অদাবিকৃত', + 'intelligence.teams.pickedUpBy': '{name} গ্রহণ করেছেন', + 'intelligence.teams.depLockTitle': '{count}টি অপূর্ণ নির্ভরতা', + 'intelligence.teams.gate.pending': 'গেট মুলতুবি', + 'intelligence.teams.gate.passed': 'গেট উত্তীর্ণ', + 'intelligence.teams.gate.failed': 'গেট ব্যর্থ', + 'intelligence.teams.gate.label': 'গেট: {status}', + 'intelligence.teams.activity.title': 'টিম কার্যকলাপ', + 'intelligence.teams.activity.empty': 'এখনো কোনো বার্তা নেই', + 'intelligence.teams.activity.toTeam': 'টিম', 'intelligence.refine.objectiveDefault': 'উৎস টাস্কটিকে বাস্তবায়নের জন্য প্রস্তুত একটি এজেন্ট টাস্কে রূপান্তর করুন: {title}', 'intelligence.refine.sourceLine': 'উৎস: {url}', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index c413038f3..f185f38b5 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -407,6 +407,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'Agentenarbeit', 'memory.tab.agentWorkDescription': 'Eine Kommandozentrale für jeden Hintergrund-Agentenlauf — gruppiert danach, was deine Eingabe braucht, was läuft und was abgeschlossen ist.', + 'memory.tab.teams': 'Teams', + 'memory.tab.teamsDescription': + 'Koordinierte Agententeams — wer für welche Aufgabe zuständig ist, was blockiert ist und was sich die Teammitglieder gegenseitig mitteilen.', 'memory.tab.agents': 'Bibliothek', 'memory.tab.agentsDescription': 'Verfügbare Agenten durchsuchen und starten — jeder mit eigenen Werkzeugen, Fähigkeiten und Schwerpunkten.', @@ -3025,6 +3028,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Workflow-Unterelement', 'intelligence.agentWork.openThread': 'Thread öffnen', 'intelligence.agentWork.openWorker': 'Worker öffnen', + 'intelligence.teams.subtitle': 'Koordinierte Agententeams und die Aufgaben, die sie teilen.', + 'intelligence.teams.loading': 'Teams werden geladen…', + 'intelligence.teams.failedToLoad': 'Teams konnten nicht geladen werden', + 'intelligence.teams.empty': 'Noch keine Agententeams', + 'intelligence.teams.emptyHint': + 'Teams erscheinen hier, wenn ein Agent koordinierte Unteragenten erzeugt, die ein gemeinsames Ziel verfolgen.', + 'intelligence.teams.refresh': 'Aktualisieren', + 'intelligence.teams.backToList': 'Alle Teams', + 'intelligence.teams.status.active': 'Aktiv', + 'intelligence.teams.status.closed': 'Geschlossen', + 'intelligence.teams.header.lead': 'Leitung', + 'intelligence.teams.header.taskCount': '{count} Aufgaben', + 'intelligence.teams.header.memberCount': '{count} Mitglieder', + 'intelligence.teams.member.active': 'Aktiv', + 'intelligence.teams.member.pending': 'Ausstehend', + 'intelligence.teams.member.idle': 'Inaktiv', + 'intelligence.teams.member.stopped': 'Gestoppt', + 'intelligence.teams.column.todo': 'Zu tun', + 'intelligence.teams.column.ready': 'Bereit', + 'intelligence.teams.column.inProgress': 'In Bearbeitung', + 'intelligence.teams.column.blocked': 'Blockiert', + 'intelligence.teams.column.done': 'Fertig', + 'intelligence.teams.emptyColumn': 'Leer', + 'intelligence.teams.unclaimed': 'Nicht zugewiesen', + 'intelligence.teams.pickedUpBy': 'Übernommen von {name}', + 'intelligence.teams.depLockTitle': '{count} nicht erfüllte Abhängigkeiten', + 'intelligence.teams.gate.pending': 'Prüfung ausstehend', + 'intelligence.teams.gate.passed': 'Prüfung bestanden', + 'intelligence.teams.gate.failed': 'Prüfung fehlgeschlagen', + 'intelligence.teams.gate.label': 'Prüfung: {status}', + 'intelligence.teams.activity.title': 'Teamaktivität', + 'intelligence.teams.activity.empty': 'Noch keine Nachrichten', + 'intelligence.teams.activity.toTeam': 'Team', 'intelligence.refine.objectiveDefault': 'Verwandle die Quellaufgabe in eine umsetzungsbereite Agentenaufgabe: {title}', 'intelligence.refine.sourceLine': 'Quelle: {url}', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index c63556193..bd9020fe7 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -436,6 +436,9 @@ const en: TranslationMap = { 'memory.tab.agentWork': 'Agent Work', 'memory.tab.agentWorkDescription': 'A command center for every background agent run — grouped by what needs your input, what is working, and what has finished.', + 'memory.tab.teams': 'Teams', + 'memory.tab.teamsDescription': + 'Coordinated agent teams — who owns each task, what is blocked, and what the teammates are saying to each other.', 'memory.tab.agents': 'Library', 'memory.tab.agentsDescription': 'Browse and run your available agents — each with its own tools, capabilities, and focus area.', @@ -3491,6 +3494,39 @@ const en: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Workflow child', 'intelligence.agentWork.openThread': 'Open thread', 'intelligence.agentWork.openWorker': 'Open worker', + 'intelligence.teams.subtitle': 'Coordinated agent teams and the tasks they share.', + 'intelligence.teams.loading': 'Loading teams…', + 'intelligence.teams.failedToLoad': 'Failed to load teams', + 'intelligence.teams.empty': 'No agent teams yet', + 'intelligence.teams.emptyHint': + 'Teams appear here when an agent spawns coordinated sub-agents to share a goal.', + 'intelligence.teams.refresh': 'Refresh', + 'intelligence.teams.backToList': 'All teams', + 'intelligence.teams.status.active': 'Active', + 'intelligence.teams.status.closed': 'Closed', + 'intelligence.teams.header.lead': 'lead', + 'intelligence.teams.header.taskCount': '{count} tasks', + 'intelligence.teams.header.memberCount': '{count} members', + 'intelligence.teams.member.active': 'Active', + 'intelligence.teams.member.pending': 'Pending', + 'intelligence.teams.member.idle': 'Idle', + 'intelligence.teams.member.stopped': 'Stopped', + 'intelligence.teams.column.todo': 'To do', + 'intelligence.teams.column.ready': 'Ready', + 'intelligence.teams.column.inProgress': 'In progress', + 'intelligence.teams.column.blocked': 'Blocked', + 'intelligence.teams.column.done': 'Done', + 'intelligence.teams.emptyColumn': 'Empty', + 'intelligence.teams.unclaimed': 'Unclaimed', + 'intelligence.teams.pickedUpBy': 'Picked up by {name}', + 'intelligence.teams.depLockTitle': '{count} unmet dependencies', + 'intelligence.teams.gate.pending': 'Gate pending', + 'intelligence.teams.gate.passed': 'Gate passed', + 'intelligence.teams.gate.failed': 'Gate failed', + 'intelligence.teams.gate.label': 'Gate: {status}', + 'intelligence.teams.activity.title': 'Team activity', + 'intelligence.teams.activity.empty': 'No messages yet', + 'intelligence.teams.activity.toTeam': 'team', 'intelligence.refine.objectiveDefault': 'Turn the source task into an implementation-ready agent task: {title}', 'intelligence.refine.sourceLine': 'Source: {url}', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f4d4ae699..3f7b176a1 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -408,6 +408,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'Trabajo del agente', 'memory.tab.agentWorkDescription': 'Un centro de mando para cada ejecución de agente en segundo plano, agrupado según lo que necesita tu intervención, lo que está en curso y lo que ha terminado.', + 'memory.tab.teams': 'Equipos', + 'memory.tab.teamsDescription': + 'Equipos de agentes coordinados: quién se encarga de cada tarea, qué está bloqueado y qué se dicen los compañeros entre sí.', 'memory.tab.agents': 'Biblioteca', 'memory.tab.agentsDescription': 'Explora y ejecuta los agentes disponibles — cada uno con sus propias herramientas, capacidades y área de enfoque.', @@ -3010,6 +3013,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Hijo de flujo de trabajo', 'intelligence.agentWork.openThread': 'Abrir hilo', 'intelligence.agentWork.openWorker': 'Abrir trabajador', + 'intelligence.teams.subtitle': 'Equipos de agentes coordinados y las tareas que comparten.', + 'intelligence.teams.loading': 'Cargando equipos…', + 'intelligence.teams.failedToLoad': 'Error al cargar los equipos', + 'intelligence.teams.empty': 'Aún no hay equipos de agentes', + 'intelligence.teams.emptyHint': + 'Los equipos aparecen aquí cuando un agente genera subagentes coordinados para compartir un objetivo.', + 'intelligence.teams.refresh': 'Actualizar', + 'intelligence.teams.backToList': 'Todos los equipos', + 'intelligence.teams.status.active': 'Activo', + 'intelligence.teams.status.closed': 'Cerrado', + 'intelligence.teams.header.lead': 'líder', + 'intelligence.teams.header.taskCount': '{count} tareas', + 'intelligence.teams.header.memberCount': '{count} miembros', + 'intelligence.teams.member.active': 'Activo', + 'intelligence.teams.member.pending': 'Pendiente', + 'intelligence.teams.member.idle': 'Inactivo', + 'intelligence.teams.member.stopped': 'Detenido', + 'intelligence.teams.column.todo': 'Pendiente', + 'intelligence.teams.column.ready': 'Listo', + 'intelligence.teams.column.inProgress': 'En progreso', + 'intelligence.teams.column.blocked': 'Bloqueado', + 'intelligence.teams.column.done': 'Completado', + 'intelligence.teams.emptyColumn': 'Vacío', + 'intelligence.teams.unclaimed': 'Sin asignar', + 'intelligence.teams.pickedUpBy': 'Asumido por {name}', + 'intelligence.teams.depLockTitle': '{count} dependencias sin cumplir', + 'intelligence.teams.gate.pending': 'Control pendiente', + 'intelligence.teams.gate.passed': 'Control superado', + 'intelligence.teams.gate.failed': 'Control fallido', + 'intelligence.teams.gate.label': 'Control: {status}', + 'intelligence.teams.activity.title': 'Actividad del equipo', + 'intelligence.teams.activity.empty': 'Aún no hay mensajes', + 'intelligence.teams.activity.toTeam': 'equipo', 'intelligence.refine.objectiveDefault': 'Convierte la tarea de origen en una tarea de agente lista para implementar: {title}', 'intelligence.refine.sourceLine': 'Origen: {url}', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 7ac0abee5..1eba2a393 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -408,6 +408,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': "Travail de l'agent", 'memory.tab.agentWorkDescription': "Un centre de commande pour chaque exécution d'agent en arrière-plan, regroupée selon ce qui nécessite votre intervention, ce qui est en cours et ce qui est terminé.", + 'memory.tab.teams': 'Équipes', + 'memory.tab.teamsDescription': + "Équipes d'agents coordonnées — qui s'occupe de chaque tâche, ce qui est bloqué et ce que les coéquipiers se disent entre eux.", 'memory.tab.agents': 'Bibliothèque', 'memory.tab.agentsDescription': 'Parcourez et exécutez vos agents disponibles — chacun avec ses propres outils, capacités et domaine de spécialisation.', @@ -3025,6 +3028,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Élément enfant de workflow', 'intelligence.agentWork.openThread': 'Ouvrir le fil', 'intelligence.agentWork.openWorker': 'Ouvrir le worker', + 'intelligence.teams.subtitle': "Équipes d'agents coordonnées et les tâches qu'elles partagent.", + 'intelligence.teams.loading': 'Chargement des équipes…', + 'intelligence.teams.failedToLoad': 'Échec du chargement des équipes', + 'intelligence.teams.empty': "Pas encore d'équipes d'agents", + 'intelligence.teams.emptyHint': + "Les équipes apparaissent ici lorsqu'un agent génère des sous-agents coordonnés pour partager un objectif.", + 'intelligence.teams.refresh': 'Actualiser', + 'intelligence.teams.backToList': 'Toutes les équipes', + 'intelligence.teams.status.active': 'Actif', + 'intelligence.teams.status.closed': 'Fermé', + 'intelligence.teams.header.lead': 'responsable', + 'intelligence.teams.header.taskCount': '{count} tâches', + 'intelligence.teams.header.memberCount': '{count} membres', + 'intelligence.teams.member.active': 'Actif', + 'intelligence.teams.member.pending': 'En attente', + 'intelligence.teams.member.idle': 'Inactif', + 'intelligence.teams.member.stopped': 'Arrêté', + 'intelligence.teams.column.todo': 'À faire', + 'intelligence.teams.column.ready': 'Prêt', + 'intelligence.teams.column.inProgress': 'En cours', + 'intelligence.teams.column.blocked': 'Bloqué', + 'intelligence.teams.column.done': 'Terminé', + 'intelligence.teams.emptyColumn': 'Vide', + 'intelligence.teams.unclaimed': 'Non attribué', + 'intelligence.teams.pickedUpBy': 'Pris en charge par {name}', + 'intelligence.teams.depLockTitle': '{count} dépendances non satisfaites', + 'intelligence.teams.gate.pending': 'Contrôle en attente', + 'intelligence.teams.gate.passed': 'Contrôle réussi', + 'intelligence.teams.gate.failed': 'Contrôle échoué', + 'intelligence.teams.gate.label': 'Contrôle : {status}', + 'intelligence.teams.activity.title': "Activité de l'équipe", + 'intelligence.teams.activity.empty': "Aucun message pour l'instant", + 'intelligence.teams.activity.toTeam': 'équipe', 'intelligence.refine.sourceIntake': 'Source : réception des sources de tâches', 'intelligence.refine.repositoryLine': 'Dépôt : {repo}', 'intelligence.refine.externalTaskLine': 'Tâche externe : {externalId}', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 2dc92ee25..f2680e583 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -394,6 +394,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'एजेंट कार्य', 'memory.tab.agentWorkDescription': 'हर पृष्ठभूमि एजेंट रन के लिए एक कमांड सेंटर — इस आधार पर समूहित कि किसे आपकी जानकारी चाहिए, क्या चल रहा है और क्या पूरा हो चुका है।', + 'memory.tab.teams': 'टीमें', + 'memory.tab.teamsDescription': + 'समन्वित एजेंट टीमें — हर कार्य का स्वामी कौन है, क्या अवरुद्ध है, और सहयोगी एक-दूसरे से क्या कह रहे हैं।', 'memory.tab.agents': 'लाइब्रेरी', 'memory.tab.agentsDescription': 'अपने उपलब्ध एजेंट ब्राउज़ करें और चलाएं — प्रत्येक के पास अपने टूल, क्षमताएं और फोकस क्षेत्र हैं।', @@ -2955,6 +2958,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'वर्कफ़्लो चाइल्ड', 'intelligence.agentWork.openThread': 'थ्रेड खोलें', 'intelligence.agentWork.openWorker': 'वर्कर खोलें', + 'intelligence.teams.subtitle': 'समन्वित एजेंट टीमें और वे कार्य जो वे साझा करती हैं।', + 'intelligence.teams.loading': 'टीमें लोड हो रही हैं…', + 'intelligence.teams.failedToLoad': 'टीमें लोड करने में विफल', + 'intelligence.teams.empty': 'अभी तक कोई एजेंट टीम नहीं', + 'intelligence.teams.emptyHint': + 'जब कोई एजेंट किसी लक्ष्य को साझा करने के लिए समन्वित सब-एजेंट बनाता है, तब टीमें यहाँ दिखाई देती हैं।', + 'intelligence.teams.refresh': 'रिफ्रेश करें', + 'intelligence.teams.backToList': 'सभी टीमें', + 'intelligence.teams.status.active': 'सक्रिय', + 'intelligence.teams.status.closed': 'बंद', + 'intelligence.teams.header.lead': 'लीड', + 'intelligence.teams.header.taskCount': '{count} कार्य', + 'intelligence.teams.header.memberCount': '{count} सदस्य', + 'intelligence.teams.member.active': 'सक्रिय', + 'intelligence.teams.member.pending': 'लंबित', + 'intelligence.teams.member.idle': 'निष्क्रिय', + 'intelligence.teams.member.stopped': 'रुका हुआ', + 'intelligence.teams.column.todo': 'करना है', + 'intelligence.teams.column.ready': 'तैयार', + 'intelligence.teams.column.inProgress': 'प्रगति पर', + 'intelligence.teams.column.blocked': 'अवरुद्ध', + 'intelligence.teams.column.done': 'पूर्ण', + 'intelligence.teams.emptyColumn': 'खाली', + 'intelligence.teams.unclaimed': 'अनिर्दिष्ट', + 'intelligence.teams.pickedUpBy': '{name} ने लिया', + 'intelligence.teams.depLockTitle': '{count} अधूरी निर्भरताएँ', + 'intelligence.teams.gate.pending': 'गेट लंबित', + 'intelligence.teams.gate.passed': 'गेट पास', + 'intelligence.teams.gate.failed': 'गेट विफल', + 'intelligence.teams.gate.label': 'गेट: {status}', + 'intelligence.teams.activity.title': 'टीम गतिविधि', + 'intelligence.teams.activity.empty': 'अभी कोई संदेश नहीं', + 'intelligence.teams.activity.toTeam': 'टीम', 'intelligence.refine.objectiveDefault': 'स्रोत कार्य को कार्यान्वयन के लिए तैयार एजेंट कार्य में बदलें: {title}', 'intelligence.refine.sourceLine': 'स्रोत: {url}', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 13dba411a..933098a95 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -398,6 +398,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'Kerja agen', 'memory.tab.agentWorkDescription': 'Pusat kendali untuk setiap proses agen latar belakang — dikelompokkan berdasarkan apa yang butuh masukan Anda, apa yang sedang berjalan, dan apa yang sudah selesai.', + 'memory.tab.teams': 'Tim', + 'memory.tab.teamsDescription': + 'Tim agen yang terkoordinasi — siapa yang menangani setiap tugas, apa yang terhambat, dan apa yang dibicarakan rekan satu tim satu sama lain.', 'memory.tab.agents': 'Perpustakaan', 'memory.tab.agentsDescription': 'Telusuri dan jalankan agen yang tersedia — masing-masing dengan alat, kemampuan, dan area fokusnya sendiri.', @@ -2960,6 +2963,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Anak alur kerja', 'intelligence.agentWork.openThread': 'Buka utas', 'intelligence.agentWork.openWorker': 'Buka pekerja', + 'intelligence.teams.subtitle': 'Tim agen yang terkoordinasi dan tugas yang mereka bagikan.', + 'intelligence.teams.loading': 'Memuat tim…', + 'intelligence.teams.failedToLoad': 'Gagal memuat tim', + 'intelligence.teams.empty': 'Belum ada tim agen', + 'intelligence.teams.emptyHint': + 'Tim muncul di sini ketika sebuah agen membuat sub-agen terkoordinasi untuk berbagi satu tujuan.', + 'intelligence.teams.refresh': 'Segarkan', + 'intelligence.teams.backToList': 'Semua tim', + 'intelligence.teams.status.active': 'Aktif', + 'intelligence.teams.status.closed': 'Ditutup', + 'intelligence.teams.header.lead': 'pemimpin', + 'intelligence.teams.header.taskCount': '{count} tugas', + 'intelligence.teams.header.memberCount': '{count} anggota', + 'intelligence.teams.member.active': 'Aktif', + 'intelligence.teams.member.pending': 'Tertunda', + 'intelligence.teams.member.idle': 'Diam', + 'intelligence.teams.member.stopped': 'Dihentikan', + 'intelligence.teams.column.todo': 'Akan dikerjakan', + 'intelligence.teams.column.ready': 'Siap', + 'intelligence.teams.column.inProgress': 'Sedang berjalan', + 'intelligence.teams.column.blocked': 'Terhambat', + 'intelligence.teams.column.done': 'Selesai', + 'intelligence.teams.emptyColumn': 'Kosong', + 'intelligence.teams.unclaimed': 'Belum diambil', + 'intelligence.teams.pickedUpBy': 'Diambil oleh {name}', + 'intelligence.teams.depLockTitle': '{count} dependensi belum terpenuhi', + 'intelligence.teams.gate.pending': 'Gerbang tertunda', + 'intelligence.teams.gate.passed': 'Gerbang lolos', + 'intelligence.teams.gate.failed': 'Gerbang gagal', + 'intelligence.teams.gate.label': 'Gerbang: {status}', + 'intelligence.teams.activity.title': 'Aktivitas tim', + 'intelligence.teams.activity.empty': 'Belum ada pesan', + 'intelligence.teams.activity.toTeam': 'tim', 'intelligence.refine.objectiveDefault': 'Ubah tugas sumber menjadi tugas agen yang siap diimplementasikan: {title}', 'intelligence.refine.sourceLine': 'Sumber: {url}', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 65d1e66f7..505e30495 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -404,6 +404,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': "Lavoro dell'agente", 'memory.tab.agentWorkDescription': 'Un centro di comando per ogni esecuzione di agente in background, raggruppata in base a ciò che richiede il tuo intervento, ciò che è in corso e ciò che è terminato.', + 'memory.tab.teams': 'Team', + 'memory.tab.teamsDescription': + 'Team di agenti coordinati — chi è responsabile di ogni attività, cosa è bloccato e cosa si dicono i compagni di team tra loro.', 'memory.tab.agents': 'Libreria', 'memory.tab.agentsDescription': 'Sfoglia e avvia gli agenti disponibili — ognuno con i propri strumenti, capacità e area di specializzazione.', @@ -3003,6 +3006,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Figlio del flusso di lavoro', 'intelligence.agentWork.openThread': 'Apri thread', 'intelligence.agentWork.openWorker': 'Apri worker', + 'intelligence.teams.subtitle': 'Team di agenti coordinati e le attività che condividono.', + 'intelligence.teams.loading': 'Caricamento dei team…', + 'intelligence.teams.failedToLoad': 'Impossibile caricare i team', + 'intelligence.teams.empty': 'Ancora nessun team di agenti', + 'intelligence.teams.emptyHint': + 'I team compaiono qui quando un agente genera sotto-agenti coordinati per condividere un obiettivo.', + 'intelligence.teams.refresh': 'Aggiorna', + 'intelligence.teams.backToList': 'Tutti i team', + 'intelligence.teams.status.active': 'Attivo', + 'intelligence.teams.status.closed': 'Chiuso', + 'intelligence.teams.header.lead': 'responsabile', + 'intelligence.teams.header.taskCount': '{count} attività', + 'intelligence.teams.header.memberCount': '{count} membri', + 'intelligence.teams.member.active': 'Attivo', + 'intelligence.teams.member.pending': 'In attesa', + 'intelligence.teams.member.idle': 'Inattivo', + 'intelligence.teams.member.stopped': 'Interrotto', + 'intelligence.teams.column.todo': 'Da fare', + 'intelligence.teams.column.ready': 'Pronto', + 'intelligence.teams.column.inProgress': 'In corso', + 'intelligence.teams.column.blocked': 'Bloccato', + 'intelligence.teams.column.done': 'Fatto', + 'intelligence.teams.emptyColumn': 'Vuoto', + 'intelligence.teams.unclaimed': 'Non assegnato', + 'intelligence.teams.pickedUpBy': 'Preso in carico da {name}', + 'intelligence.teams.depLockTitle': '{count} dipendenze non soddisfatte', + 'intelligence.teams.gate.pending': 'Controllo in attesa', + 'intelligence.teams.gate.passed': 'Controllo superato', + 'intelligence.teams.gate.failed': 'Controllo fallito', + 'intelligence.teams.gate.label': 'Controllo: {status}', + 'intelligence.teams.activity.title': 'Attività del team', + 'intelligence.teams.activity.empty': 'Nessun messaggio', + 'intelligence.teams.activity.toTeam': 'team', 'intelligence.refine.objectiveDefault': "Trasforma l'attività di origine in un'attività dell'agente pronta per l'implementazione: {title}", 'intelligence.refine.sourceLine': 'Origine: {url}', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 45317eb5c..0dcec2d23 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -395,6 +395,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': '에이전트 작업', 'memory.tab.agentWorkDescription': '모든 백그라운드 에이전트 실행을 위한 명령 센터 — 입력이 필요한 것, 진행 중인 것, 완료된 것별로 그룹화됩니다.', + 'memory.tab.teams': '팀', + 'memory.tab.teamsDescription': + '조율된 에이전트 팀 — 각 작업을 누가 담당하는지, 무엇이 차단되어 있는지, 그리고 팀원들이 서로 무슨 이야기를 나누는지.', 'memory.tab.agents': '라이브러리', 'memory.tab.agentsDescription': '사용 가능한 에이전트를 탐색하고 실행하세요 — 각 에이전트는 고유한 도구, 기능 및 전문 분야를 갖추고 있습니다.', @@ -2930,6 +2933,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': '워크플로 하위 항목', 'intelligence.agentWork.openThread': '스레드 열기', 'intelligence.agentWork.openWorker': '워커 열기', + 'intelligence.teams.subtitle': '조율된 에이전트 팀과 그들이 공유하는 작업.', + 'intelligence.teams.loading': '팀 로딩 중…', + 'intelligence.teams.failedToLoad': '팀을 불러오지 못했습니다', + 'intelligence.teams.empty': '아직 에이전트 팀이 없습니다', + 'intelligence.teams.emptyHint': + '에이전트가 하나의 목표를 공유하기 위해 조율된 하위 에이전트를 생성하면 팀이 여기에 표시됩니다.', + 'intelligence.teams.refresh': '새로고침', + 'intelligence.teams.backToList': '모든 팀', + 'intelligence.teams.status.active': '활성', + 'intelligence.teams.status.closed': '종료됨', + 'intelligence.teams.header.lead': '리드', + 'intelligence.teams.header.taskCount': '작업 {count}개', + 'intelligence.teams.header.memberCount': '구성원 {count}명', + 'intelligence.teams.member.active': '활성', + 'intelligence.teams.member.pending': '대기 중', + 'intelligence.teams.member.idle': '유휴', + 'intelligence.teams.member.stopped': '중지됨', + 'intelligence.teams.column.todo': '할 일', + 'intelligence.teams.column.ready': '준비됨', + 'intelligence.teams.column.inProgress': '진행 중', + 'intelligence.teams.column.blocked': '차단됨', + 'intelligence.teams.column.done': '완료', + 'intelligence.teams.emptyColumn': '비어 있음', + 'intelligence.teams.unclaimed': '미할당', + 'intelligence.teams.pickedUpBy': '{name}님이 맡음', + 'intelligence.teams.depLockTitle': '충족되지 않은 종속성 {count}개', + 'intelligence.teams.gate.pending': '게이트 대기 중', + 'intelligence.teams.gate.passed': '게이트 통과', + 'intelligence.teams.gate.failed': '게이트 실패', + 'intelligence.teams.gate.label': '게이트: {status}', + 'intelligence.teams.activity.title': '팀 활동', + 'intelligence.teams.activity.empty': '아직 메시지가 없습니다', + 'intelligence.teams.activity.toTeam': '팀', 'intelligence.refine.objectiveDefault': '소스 작업을 구현 준비가 된 에이전트 작업으로 전환하세요: {title}', 'intelligence.refine.sourceLine': '소스: {url}', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 9a5ea5a8b..be180eb20 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -401,6 +401,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'Praca agenta', 'memory.tab.agentWorkDescription': 'Centrum dowodzenia dla każdego przebiegu agenta w tle — pogrupowane według tego, co wymaga Twojej reakcji, co działa i co zostało ukończone.', + 'memory.tab.teams': 'Zespoły', + 'memory.tab.teamsDescription': + 'Skoordynowane zespoły agentów — kto odpowiada za każde zadanie, co jest zablokowane i co członkowie zespołu mówią sobie nawzajem.', 'memory.tab.agents': 'Biblioteka', 'memory.tab.agentsDescription': 'Przeglądaj i uruchamiaj dostępnych agentów — każdy z własnymi narzędziami, możliwościami i obszarem specjalizacji.', @@ -2992,6 +2995,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Element podrzędny przepływu', 'intelligence.agentWork.openThread': 'Otwórz wątek', 'intelligence.agentWork.openWorker': 'Otwórz proces', + 'intelligence.teams.subtitle': 'Skoordynowane zespoły agentów i zadania, które dzielą.', + 'intelligence.teams.loading': 'Ładowanie zespołów…', + 'intelligence.teams.failedToLoad': 'Nie udało się załadować zespołów', + 'intelligence.teams.empty': 'Brak zespołów agentów', + 'intelligence.teams.emptyHint': + 'Zespoły pojawiają się tutaj, gdy agent tworzy skoordynowane podagenty, aby dzielić wspólny cel.', + 'intelligence.teams.refresh': 'Odśwież', + 'intelligence.teams.backToList': 'Wszystkie zespoły', + 'intelligence.teams.status.active': 'Aktywny', + 'intelligence.teams.status.closed': 'Zamknięty', + 'intelligence.teams.header.lead': 'lider', + 'intelligence.teams.header.taskCount': '{count} zadań', + 'intelligence.teams.header.memberCount': '{count} członków', + 'intelligence.teams.member.active': 'Aktywny', + 'intelligence.teams.member.pending': 'Oczekujący', + 'intelligence.teams.member.idle': 'Bezczynny', + 'intelligence.teams.member.stopped': 'Zatrzymany', + 'intelligence.teams.column.todo': 'Do zrobienia', + 'intelligence.teams.column.ready': 'Gotowe', + 'intelligence.teams.column.inProgress': 'W toku', + 'intelligence.teams.column.blocked': 'Zablokowane', + 'intelligence.teams.column.done': 'Zrobione', + 'intelligence.teams.emptyColumn': 'Puste', + 'intelligence.teams.unclaimed': 'Nieprzypisane', + 'intelligence.teams.pickedUpBy': 'Przejęte przez {name}', + 'intelligence.teams.depLockTitle': '{count} niespełnionych zależności', + 'intelligence.teams.gate.pending': 'Bramka oczekuje', + 'intelligence.teams.gate.passed': 'Bramka zaliczona', + 'intelligence.teams.gate.failed': 'Bramka niezaliczona', + 'intelligence.teams.gate.label': 'Bramka: {status}', + 'intelligence.teams.activity.title': 'Aktywność zespołu', + 'intelligence.teams.activity.empty': 'Brak wiadomości', + 'intelligence.teams.activity.toTeam': 'zespół', 'intelligence.refine.objectiveDefault': 'Przekształć zadanie źródłowe w gotowe do wdrożenia zadanie agenta: {title}', 'intelligence.refine.sourceLine': 'Źródło: {url}', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3d01393bd..8bf34e9a3 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -408,6 +408,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'Trabalho do agente', 'memory.tab.agentWorkDescription': 'Um centro de comando para cada execução de agente em segundo plano, agrupada conforme o que precisa da sua intervenção, o que está em andamento e o que foi concluído.', + 'memory.tab.teams': 'Equipes', + 'memory.tab.teamsDescription': + 'Equipes de agentes coordenadas — quem é responsável por cada tarefa, o que está bloqueado e o que os colegas de equipe estão dizendo uns aos outros.', 'memory.tab.agents': 'Biblioteca', 'memory.tab.agentsDescription': 'Navegue e execute os agentes disponíveis — cada um com suas próprias ferramentas, capacidades e área de foco.', @@ -3007,6 +3010,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Filho de fluxo de trabalho', 'intelligence.agentWork.openThread': 'Abrir tópico', 'intelligence.agentWork.openWorker': 'Abrir trabalhador', + 'intelligence.teams.subtitle': 'Equipes de agentes coordenadas e as tarefas que compartilham.', + 'intelligence.teams.loading': 'Carregando equipes…', + 'intelligence.teams.failedToLoad': 'Falha ao carregar equipes', + 'intelligence.teams.empty': 'Ainda não há equipes de agentes', + 'intelligence.teams.emptyHint': + 'As equipes aparecem aqui quando um agente gera subagentes coordenados para compartilhar um objetivo.', + 'intelligence.teams.refresh': 'Atualizar', + 'intelligence.teams.backToList': 'Todas as equipes', + 'intelligence.teams.status.active': 'Ativo', + 'intelligence.teams.status.closed': 'Fechado', + 'intelligence.teams.header.lead': 'líder', + 'intelligence.teams.header.taskCount': '{count} tarefas', + 'intelligence.teams.header.memberCount': '{count} membros', + 'intelligence.teams.member.active': 'Ativo', + 'intelligence.teams.member.pending': 'Pendente', + 'intelligence.teams.member.idle': 'Ocioso', + 'intelligence.teams.member.stopped': 'Parado', + 'intelligence.teams.column.todo': 'A fazer', + 'intelligence.teams.column.ready': 'Pronto', + 'intelligence.teams.column.inProgress': 'Em andamento', + 'intelligence.teams.column.blocked': 'Bloqueado', + 'intelligence.teams.column.done': 'Concluído', + 'intelligence.teams.emptyColumn': 'Vazio', + 'intelligence.teams.unclaimed': 'Não atribuído', + 'intelligence.teams.pickedUpBy': 'Assumido por {name}', + 'intelligence.teams.depLockTitle': '{count} dependências não atendidas', + 'intelligence.teams.gate.pending': 'Verificação pendente', + 'intelligence.teams.gate.passed': 'Verificação aprovada', + 'intelligence.teams.gate.failed': 'Verificação reprovada', + 'intelligence.teams.gate.label': 'Verificação: {status}', + 'intelligence.teams.activity.title': 'Atividade da equipe', + 'intelligence.teams.activity.empty': 'Nenhuma mensagem ainda', + 'intelligence.teams.activity.toTeam': 'equipe', 'intelligence.refine.objectiveDefault': 'Transforme a tarefa de origem em uma tarefa de agente pronta para implementação: {title}', 'intelligence.refine.sourceLine': 'Origem: {url}', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 0fec4603f..ed3f9894e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -400,6 +400,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': 'Работа агента', 'memory.tab.agentWorkDescription': 'Центр управления для каждого фонового запуска агента — сгруппировано по тому, что требует вашего участия, что выполняется и что завершено.', + 'memory.tab.teams': 'Команды', + 'memory.tab.teamsDescription': + 'Скоординированные команды агентов — кто отвечает за каждую задачу, что заблокировано и что участники говорят друг другу.', 'memory.tab.agents': 'Библиотека', 'memory.tab.agentsDescription': 'Просматривайте и запускайте доступных агентов — у каждого свои инструменты, возможности и область специализации.', @@ -2979,6 +2982,40 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': 'Дочерний элемент рабочего процесса', 'intelligence.agentWork.openThread': 'Открыть тред', 'intelligence.agentWork.openWorker': 'Открыть воркер', + 'intelligence.teams.subtitle': + 'Скоординированные команды агентов и задачи, которые они разделяют.', + 'intelligence.teams.loading': 'Загрузка команд…', + 'intelligence.teams.failedToLoad': 'Не удалось загрузить команды', + 'intelligence.teams.empty': 'Пока нет команд агентов', + 'intelligence.teams.emptyHint': + 'Команды появляются здесь, когда агент создаёт скоординированных субагентов для совместного достижения цели.', + 'intelligence.teams.refresh': 'Обновить', + 'intelligence.teams.backToList': 'Все команды', + 'intelligence.teams.status.active': 'Активна', + 'intelligence.teams.status.closed': 'Закрыта', + 'intelligence.teams.header.lead': 'ведущий', + 'intelligence.teams.header.taskCount': '{count} задач', + 'intelligence.teams.header.memberCount': '{count} участников', + 'intelligence.teams.member.active': 'Активен', + 'intelligence.teams.member.pending': 'Ожидает', + 'intelligence.teams.member.idle': 'Простаивает', + 'intelligence.teams.member.stopped': 'Остановлен', + 'intelligence.teams.column.todo': 'К выполнению', + 'intelligence.teams.column.ready': 'Готово к работе', + 'intelligence.teams.column.inProgress': 'В работе', + 'intelligence.teams.column.blocked': 'Заблокировано', + 'intelligence.teams.column.done': 'Завершено', + 'intelligence.teams.emptyColumn': 'Пусто', + 'intelligence.teams.unclaimed': 'Не назначено', + 'intelligence.teams.pickedUpBy': 'Взял в работу {name}', + 'intelligence.teams.depLockTitle': '{count} невыполненных зависимостей', + 'intelligence.teams.gate.pending': 'Проверка ожидается', + 'intelligence.teams.gate.passed': 'Проверка пройдена', + 'intelligence.teams.gate.failed': 'Проверка не пройдена', + 'intelligence.teams.gate.label': 'Проверка: {status}', + 'intelligence.teams.activity.title': 'Активность команды', + 'intelligence.teams.activity.empty': 'Сообщений пока нет', + 'intelligence.teams.activity.toTeam': 'команда', 'intelligence.refine.objectiveDefault': 'Превратите исходную задачу в готовую к реализации задачу агента: {title}', 'intelligence.refine.sourceLine': 'Источник: {url}', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 4d8de78ba..e63a0d543 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -379,6 +379,9 @@ const messages: TranslationMap = { 'memory.tab.agentWork': '智能体工作', 'memory.tab.agentWorkDescription': '所有后台智能体运行的指挥中心——按需要你处理的、正在运行的和已完成的进行分组。', + 'memory.tab.teams': '团队', + 'memory.tab.teamsDescription': + '协同的智能体团队——谁负责每项任务、有什么被阻塞,以及队友之间在交流什么。', 'memory.tab.agents': '资料库', 'memory.tab.agentsDescription': '浏览并运行可用的智能体——每个智能体都有自己的工具、能力和专注领域。', @@ -2812,6 +2815,39 @@ const messages: TranslationMap = { 'intelligence.agentWork.kind.workflowChild': '工作流子项', 'intelligence.agentWork.openThread': '打开会话', 'intelligence.agentWork.openWorker': '打开工作线程', + 'intelligence.teams.subtitle': '协同的智能体团队及其共享的任务。', + 'intelligence.teams.loading': '正在加载团队…', + 'intelligence.teams.failedToLoad': '加载团队失败', + 'intelligence.teams.empty': '暂无智能体团队', + 'intelligence.teams.emptyHint': + '当某个智能体生成协同的子智能体以共享同一目标时,团队会显示在这里。', + 'intelligence.teams.refresh': '刷新', + 'intelligence.teams.backToList': '所有团队', + 'intelligence.teams.status.active': '活跃', + 'intelligence.teams.status.closed': '已关闭', + 'intelligence.teams.header.lead': '负责人', + 'intelligence.teams.header.taskCount': '{count} 个任务', + 'intelligence.teams.header.memberCount': '{count} 名成员', + 'intelligence.teams.member.active': '活跃', + 'intelligence.teams.member.pending': '待处理', + 'intelligence.teams.member.idle': '空闲', + 'intelligence.teams.member.stopped': '已停止', + 'intelligence.teams.column.todo': '待办', + 'intelligence.teams.column.ready': '就绪', + 'intelligence.teams.column.inProgress': '进行中', + 'intelligence.teams.column.blocked': '已阻塞', + 'intelligence.teams.column.done': '已完成', + 'intelligence.teams.emptyColumn': '空', + 'intelligence.teams.unclaimed': '未认领', + 'intelligence.teams.pickedUpBy': '由 {name} 接手', + 'intelligence.teams.depLockTitle': '{count} 项未满足的依赖', + 'intelligence.teams.gate.pending': '关卡待定', + 'intelligence.teams.gate.passed': '关卡通过', + 'intelligence.teams.gate.failed': '关卡失败', + 'intelligence.teams.gate.label': '关卡:{status}', + 'intelligence.teams.activity.title': '团队动态', + 'intelligence.teams.activity.empty': '暂无消息', + 'intelligence.teams.activity.toTeam': '团队', 'intelligence.refine.objectiveDefault': '将来源任务转化为可立即实施的智能体任务:{title}', 'intelligence.refine.sourceLine': '来源:{url}', 'intelligence.refine.sourceIntake': '来源:任务来源接收', diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index cc734e050..f29a3d504 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -6,6 +6,7 @@ import IntelligenceAgentsTab from '../components/intelligence/IntelligenceAgents import IntelligenceAgentWorkTab from '../components/intelligence/IntelligenceAgentWorkTab'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab'; +import IntelligenceTeamsTab from '../components/intelligence/IntelligenceTeamsTab'; import MemorySection from '../components/intelligence/MemorySection'; import ModelCouncilTab from '../components/intelligence/ModelCouncilTab'; import { ToastContainer } from '../components/intelligence/Toast'; @@ -28,6 +29,7 @@ type IntelligenceTab = | 'subconscious' | 'tasks' | 'agent-work' + | 'teams' | 'agents' | 'workflows' | 'council'; @@ -37,6 +39,7 @@ const INTELLIGENCE_TABS: IntelligenceTab[] = [ 'subconscious', 'tasks', 'agent-work', + 'teams', 'agents', 'workflows', 'council', @@ -146,6 +149,7 @@ export default function Intelligence() { label: t('memory.tab.agentWork'), description: t('memory.tab.agentWorkDescription'), }, + { id: 'teams', label: t('memory.tab.teams'), description: t('memory.tab.teamsDescription') }, { id: 'memory', label: t('memory.tab.memory') }, { id: 'subconscious', label: t('memory.tab.subconscious') }, { @@ -235,6 +239,8 @@ export default function Intelligence() { {activeTab === 'agent-work' && } + {activeTab === 'teams' && } + {activeTab === 'agents' && } {activeTab === 'workflows' && } diff --git a/app/src/services/api/agentTeamApi.test.ts b/app/src/services/api/agentTeamApi.test.ts new file mode 100644 index 000000000..88dcaac90 --- /dev/null +++ b/app/src/services/api/agentTeamApi.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { callCoreRpc } from '../coreRpcClient'; +import { agentTeamApi } from './agentTeamApi'; + +vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +const mockCall = vi.mocked(callCoreRpc); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('agentTeamApi.list', () => { + it('unwraps the { teams, count } envelope to an array', async () => { + mockCall.mockResolvedValueOnce({ teams: [{ id: 't1' }, { id: 't2' }], count: 2 }); + const teams = await agentTeamApi.list(); + expect(teams).toHaveLength(2); + expect(teams[0].id).toBe('t1'); + expect(mockCall).toHaveBeenCalledWith({ method: 'openhuman.agent_team_list', params: {} }); + }); + + it('returns [] when the envelope omits teams', async () => { + mockCall.mockResolvedValueOnce({ count: 0 }); + expect(await agentTeamApi.list()).toEqual([]); + }); + + it('forwards filters as params', async () => { + mockCall.mockResolvedValueOnce({ teams: [], count: 0 }); + await agentTeamApi.list({ parentThreadId: 'thread-1', status: 'active', limit: 10 }); + expect(mockCall).toHaveBeenCalledWith({ + method: 'openhuman.agent_team_list', + params: { parentThreadId: 'thread-1', status: 'active', limit: 10 }, + }); + }); + + it('rejects a non-positive limit without calling core', async () => { + await expect(agentTeamApi.list({ limit: 0 })).rejects.toThrow('positive integer'); + await expect(agentTeamApi.list({ limit: 1.5 })).rejects.toThrow('positive integer'); + expect(mockCall).not.toHaveBeenCalled(); + }); +}); + +describe('agentTeamApi.get', () => { + it('unwraps { team } to the TeamView', async () => { + const view = { team: { id: 't1' }, members: [], tasks: [] }; + mockCall.mockResolvedValueOnce({ team: view }); + const result = await agentTeamApi.get('t1'); + expect(result).toBe(view); + expect(mockCall).toHaveBeenCalledWith({ + method: 'openhuman.agent_team_get', + params: { teamId: 't1' }, + }); + }); + + it('returns null when the team is unknown ({ team: null })', async () => { + mockCall.mockResolvedValueOnce({ team: null }); + expect(await agentTeamApi.get('missing')).toBeNull(); + }); + + it('throws when teamId is empty, without calling core', async () => { + await expect(agentTeamApi.get('')).rejects.toThrow('teamId is required'); + expect(mockCall).not.toHaveBeenCalled(); + }); +}); + +describe('agentTeamApi.listMessages', () => { + it('unwraps { messages } and narrows each run-event payload', async () => { + mockCall.mockResolvedValueOnce({ + messages: [ + { + runId: 'team-1', + sequence: 1, + eventType: 'team_message', + payload: { from: 'm1', to: 'm2', content: 'hi', visibility: 'team' }, + timestamp: '2026-01-01T00:00:00Z', + }, + ], + }); + const messages = await agentTeamApi.listMessages('team-1'); + expect(messages).toHaveLength(1); + expect(messages[0].payload).toEqual({ + from: 'm1', + to: 'm2', + content: 'hi', + visibility: 'team', + }); + }); + + it('defends against a malformed payload (missing fields → safe defaults)', async () => { + mockCall.mockResolvedValueOnce({ + messages: [ + { + runId: 'team-1', + sequence: 2, + eventType: 'team_message', + payload: { from: 'm1' }, + timestamp: '2026-01-01T00:00:00Z', + }, + ], + }); + const [msg] = await agentTeamApi.listMessages('team-1'); + expect(msg.payload).toEqual({ from: 'm1', to: null, content: '', visibility: 'team' }); + }); + + it('returns [] when the envelope omits messages', async () => { + mockCall.mockResolvedValueOnce({}); + expect(await agentTeamApi.listMessages('team-1')).toEqual([]); + }); + + it('forwards an explicit limit, omits it otherwise', async () => { + mockCall.mockResolvedValueOnce({ messages: [] }); + await agentTeamApi.listMessages('team-1', 50); + expect(mockCall).toHaveBeenCalledWith({ + method: 'openhuman.agent_team_list_messages', + params: { teamId: 'team-1', limit: 50 }, + }); + mockCall.mockResolvedValueOnce({ messages: [] }); + await agentTeamApi.listMessages('team-1'); + expect(mockCall).toHaveBeenLastCalledWith({ + method: 'openhuman.agent_team_list_messages', + params: { teamId: 'team-1' }, + }); + }); +}); diff --git a/app/src/services/api/agentTeamApi.ts b/app/src/services/api/agentTeamApi.ts new file mode 100644 index 000000000..6fe8aa94b --- /dev/null +++ b/app/src/services/api/agentTeamApi.ts @@ -0,0 +1,197 @@ +/** + * Frontend client for the durable agent-team coordination surface (#3374). + * + * Wraps the read paths of the `openhuman.agent_team_*` controller family that + * landed with the durable team ledger (PR1, #3546): `agent_team_list`, + * `agent_team_get`, and `agent_team_list_messages`. PR2 is a READ-ONLY surface + * — creating teams, assigning/claiming tasks and posting messages is the + * agents' job (and a later PR), so only the three read methods live here. + * + * The Rust controllers serialize their row types with + * `#[serde(rename_all = "camelCase")]`, so the wire payload is already camelCase + * and no snake/camel transform is needed. What this client DOES own is + * normalizing the controllers' inconsistent response envelopes — `get` returns + * `{ team }`, `list` returns `{ teams, count }`, `list_messages` returns + * `{ messages }` — into the clean shapes the UI consumes. Quarantining that + * here means components never see the wrapper objects, and if the backend later + * normalizes its envelopes only this file changes. + */ +import debug from 'debug'; + +import { callCoreRpc } from '../coreRpcClient'; + +const log = debug('agentTeamApi'); + +/** Lifecycle of a team. Mirrors Rust `AgentTeamStatus`. */ +export type AgentTeamStatus = 'active' | 'closed'; + +/** Lifecycle of a single member. Mirrors Rust `AgentTeamMemberStatus`. */ +export type AgentTeamMemberStatus = 'pending' | 'active' | 'idle' | 'stopped'; + +/** Lifecycle of a coordination task. Mirrors Rust `AgentTeamTaskStatus`. */ +export type AgentTeamTaskStatus = 'todo' | 'ready' | 'in_progress' | 'blocked' | 'done'; + +/** A team header row. Mirrors Rust `AgentTeam`. */ +export interface AgentTeam { + id: string; + parentThreadId?: string | null; + leadAgentId: string; + status: AgentTeamStatus; + summary?: string | null; + createdAt: string; + updatedAt: string; + closedAt?: string | null; +} + +/** A member of a team. Mirrors Rust `AgentTeamMember`. */ +export interface AgentTeamMember { + id: string; + teamId: string; + name: string; + agentId?: string | null; + memberStatus: AgentTeamMemberStatus; + currentTaskId?: string | null; + workerThreadId?: string | null; + runId?: string | null; + createdAt: string; + updatedAt: string; +} + +/** A coordination task within a team. Mirrors Rust `AgentTeamTask`. */ +export interface AgentTeamTask { + id: string; + teamId: string; + title: string; + objective?: string | null; + status: AgentTeamTaskStatus; + ownerMemberId?: string | null; + claimedByMemberId?: string | null; + claimToken?: string | null; + /** Task ids this task depends on. Unmet (non-`done`) deps block a claim. */ + dependsOn: string[]; + /** Free-form gate status. Known: `pending` / `passed` / `failed`; default `pending`. */ + gateStatus: string; + gateReason?: string | null; + evidence: string[]; + sourceRunId?: string | null; + orderIndex: number; + createdAt: string; + updatedAt: string; +} + +/** A team plus its members and tasks. Mirrors Rust `TeamView` (the `get` shape). */ +export interface TeamView { + team: AgentTeam; + members: AgentTeamMember[]; + tasks: AgentTeamTask[]; +} + +/** Parsed payload of a `team_message` run event. Mirrors the Rust `json!` body. */ +export interface TeamMessagePayload { + from: string; + to: string | null; + content: string; + visibility: string; +} + +/** A teammate message. A `team_message` run event with its payload typed. */ +export interface TeamMessage { + runId: string; + sequence: number; + eventType: string; + payload: TeamMessagePayload; + timestamp: string; +} + +/** Raw `RunEvent` wire shape before the payload is narrowed to a message. */ +interface RawRunEvent { + runId: string; + sequence: number; + eventType: string; + payload: unknown; + timestamp: string; +} + +/** Optional filters for {@link agentTeamApi.list}. Mirrors `AgentTeamListRequest`. */ +export interface AgentTeamListParams { + parentThreadId?: string; + status?: AgentTeamStatus; + limit?: number; + offset?: number; +} + +function assertPositiveInt(value: number | undefined, label: string): void { + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new Error(`agentTeamApi: ${label} must be a positive integer`); + } +} + +/** Coerce a raw run-event payload into a typed message payload, defensively. */ +function readMessagePayload(payload: unknown): TeamMessagePayload { + const p = (payload ?? {}) as Record; + return { + from: typeof p.from === 'string' ? p.from : '', + to: typeof p.to === 'string' ? p.to : null, + content: typeof p.content === 'string' ? p.content : '', + visibility: typeof p.visibility === 'string' ? p.visibility : 'team', + }; +} + +export const agentTeamApi = { + /** + * List team headers, newest first. Filters are optional; `parentThreadId` + * scopes to one conversation, `status` to active/closed. + * + * Unwraps the controller's `{ teams, count }` envelope down to `AgentTeam[]`. + */ + list: async (params: AgentTeamListParams = {}): Promise => { + assertPositiveInt(params.limit, 'limit'); + log('list params=%o', params); + const response = await callCoreRpc<{ teams?: AgentTeam[]; count?: number }>({ + method: 'openhuman.agent_team_list', + params, + }); + const teams = response.teams ?? []; + log('list received teams=%d count=%o', teams.length, response.count); + return teams; + }, + + /** + * Fetch one team plus its members and tasks. Returns `null` when the id is + * unknown (the controller answers `{ team: null }`). + */ + get: async (teamId: string): Promise => { + if (!teamId) throw new Error('agentTeamApi.get: teamId is required'); + log('get teamId=%s', teamId); + const response = await callCoreRpc<{ team: TeamView | null }>({ + method: 'openhuman.agent_team_get', + params: { teamId }, + }); + log('get found=%o', response.team != null); + return response.team ?? null; + }, + + /** + * List a team's teammate messages in sequence order. Unwraps the + * `{ messages }` envelope and narrows each run-event payload to a typed + * {@link TeamMessagePayload}. + */ + listMessages: async (teamId: string, limit?: number): Promise => { + if (!teamId) throw new Error('agentTeamApi.listMessages: teamId is required'); + assertPositiveInt(limit, 'limit'); + log('listMessages teamId=%s limit=%o', teamId, limit); + const response = await callCoreRpc<{ messages?: RawRunEvent[] }>({ + method: 'openhuman.agent_team_list_messages', + params: limit === undefined ? { teamId } : { teamId, limit }, + }); + const messages = (response.messages ?? []).map(event => ({ + runId: event.runId, + sequence: event.sequence, + eventType: event.eventType, + payload: readMessagePayload(event.payload), + timestamp: event.timestamp, + })); + log('listMessages received=%d', messages.length); + return messages; + }, +};