feat(intelligence): team-aware task board UI (#3374) (#3583)

This commit is contained in:
oxoxDev
2026-06-11 15:11:40 +05:30
committed by GitHub
parent 189668343d
commit fc9f162de5
26 changed files with 1988 additions and 0 deletions
@@ -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(<IntelligenceTeamsTab />);
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(<IntelligenceTeamsTab />);
await screen.findByText('intelligence.teams.empty');
expect(mockList).toHaveBeenCalledWith({ status: 'active' });
});
it('surfaces a load error', async () => {
mockList.mockRejectedValue(new Error('core down'));
render(<IntelligenceTeamsTab />);
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(<IntelligenceTeamsTab />);
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<TeamView>(resolve => {
resolveA = resolve;
});
mockGet.mockImplementation((id: string) =>
id === 'team-1' ? slowA : Promise.resolve(view(b))
);
render(<IntelligenceTeamsTab />);
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(<IntelligenceTeamsTab />);
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(<IntelligenceTeamsTab />);
// 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(<IntelligenceTeamsTab />);
// 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(<IntelligenceTeamsTab />);
await screen.findByText('A');
expect(mockList).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('intelligence.teams.refresh'));
await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2));
});
});
@@ -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<AgentTeam[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState<TeamView | null>(null);
const [messages, setMessages] = useState<TeamMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<TeamView | null>(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<string | null>(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 (
<div className="flex items-center justify-center py-10 text-stone-400 dark:text-neutral-500">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('intelligence.teams.loading')}</span>
</div>
);
}
if (error) {
return (
<div className="space-y-3">
<div className="rounded-xl border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('intelligence.teams.failedToLoad')}: {error}
</div>
<RefreshButton refreshing={refreshing} onClick={() => void refresh()} t={t} />
</div>
);
}
if (teams.length === 0) {
return (
<div className="space-y-4">
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('intelligence.teams.subtitle')}
</p>
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-stone-200 py-12 text-center dark:border-neutral-800">
<LuUsers className="h-6 w-6 text-stone-300 dark:text-neutral-600" />
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('intelligence.teams.empty')}
</p>
<p className="max-w-sm text-xs text-stone-400 dark:text-neutral-500">
{t('intelligence.teams.emptyHint')}
</p>
</div>
</div>
);
}
// Detail view — selected team's board + activity rail.
if (selectedId && view) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
{teams.length > 1 ? (
<button
type="button"
onClick={deselect}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuArrowLeft className="h-3 w-3" />
{t('intelligence.teams.backToList')}
</button>
) : (
<span />
)}
<RefreshButton refreshing={refreshing} onClick={() => void refresh()} t={t} />
</div>
<TeamHeader team={view.team} members={view.members} taskCount={view.tasks.length} />
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[1fr_300px]">
<TeamTaskBoard tasks={view.tasks} members={view.members} />
<TeamActivityRail messages={messages} members={view.members} />
</div>
</div>
);
}
// List view — pick a team.
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('intelligence.teams.subtitle')}
</p>
<RefreshButton refreshing={refreshing} onClick={() => void refresh()} t={t} />
</div>
<ul className="divide-y divide-stone-100 overflow-hidden rounded-xl border border-stone-200 bg-white dark:divide-neutral-800 dark:border-neutral-800 dark:bg-neutral-900">
{teams.map(team => (
<li key={team.id}>
<button
type="button"
onClick={() => setSelectedId(team.id)}
className="flex w-full items-center justify-between gap-3 p-3 text-left hover:bg-stone-50 dark:hover:bg-neutral-800/60">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
{team.summary?.trim() || team.leadAgentId}
</p>
<p className="truncate text-[11px] text-stone-500 dark:text-neutral-400">
{t('intelligence.teams.header.lead')}{' '}
<span className="font-mono">{team.leadAgentId}</span>
</p>
</div>
<span
className={`flex-none rounded-md px-1.5 py-0.5 text-[10px] ${
team.status === 'active'
? 'bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300'
: 'bg-stone-100 text-stone-500 dark:bg-neutral-800 dark:text-neutral-400'
}`}>
{team.status === 'active'
? t('intelligence.teams.status.active')
: t('intelligence.teams.status.closed')}
</span>
</button>
</li>
))}
</ul>
</div>
);
}
function RefreshButton({
refreshing,
onClick,
t,
}: {
refreshing: boolean;
onClick: () => void;
t: (key: string) => string;
}) {
return (
<button
type="button"
disabled={refreshing}
onClick={onClick}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuRefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
{t('intelligence.teams.refresh')}
</button>
);
}
@@ -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(<TeamActivityRail messages={[]} members={members} />);
expect(screen.getByText('intelligence.teams.activity.empty')).toBeInTheDocument();
});
it('renders sender name and message content', () => {
render(
<TeamActivityRail messages={[message(1, 'm1', 'm2', 'split the build')]} members={members} />
);
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(<TeamActivityRail messages={[message(1, 'm1', null, 'hi all')]} members={members} />);
expect(
screen.getByText('intelligence.teams.activity.toTeam', { exact: false })
).toBeInTheDocument();
});
});
@@ -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 (
<aside className="rounded-lg border border-stone-200 bg-white p-3 dark:border-neutral-800 dark:bg-neutral-900">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('intelligence.teams.activity.title')}
</h3>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">{messages.length}</span>
</div>
{messages.length === 0 ? (
<p className="py-6 text-center text-[11px] text-stone-400 dark:text-neutral-500">
{t('intelligence.teams.activity.empty')}
</p>
) : (
<div className="space-y-3">
{messages.map(message => {
const fromMember = memberById.get(message.payload.from);
const fromName = fromMember?.name ?? message.payload.from;
const color = memberColor(message.payload.from);
return (
<div key={`${message.runId}-${message.sequence}`} className="flex gap-2">
<span
className="mt-0.5 inline-flex h-5 w-5 flex-none items-center justify-center rounded-full text-[9px] font-semibold text-white"
style={{ backgroundColor: color }}>
{fromName.charAt(0).toUpperCase()}
</span>
<div className="min-w-0">
<div className="text-[10px] text-stone-400 dark:text-neutral-500">
<b className="text-stone-600 dark:text-neutral-300">{fromName}</b>
{' → '}
{nameFor(message.payload.to)}
</div>
<p className="break-words text-[11px] leading-snug text-stone-700 dark:text-neutral-200">
{message.payload.content}
</p>
</div>
</div>
);
})}
</div>
)}
</aside>
);
}
@@ -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
* (≈25 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<AgentTeamMemberStatus, string> = {
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<AgentTeamMemberStatus, string> = {
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 (
<div className="rounded-lg border border-stone-200 bg-white px-3 py-2.5 dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="break-words text-sm font-semibold text-stone-800 dark:text-neutral-100">
{title}
</div>
<div className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">
{t('intelligence.teams.header.lead')}{' '}
<span className="font-mono text-ocean-600 dark:text-ocean-300">{team.leadAgentId}</span>
{' · '}
{t('intelligence.teams.header.taskCount').replace('{count}', String(taskCount))}
{' · '}
{t('intelligence.teams.header.memberCount').replace('{count}', String(members.length))}
</div>
</div>
<div className="flex flex-wrap items-center gap-1">
{members.map(member => (
<span
key={member.id}
title={`${member.agentId ?? member.name} · ${t(MEMBER_STATUS_KEY[member.memberStatus])}`}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 bg-stone-50 px-1.5 py-0.5 text-[10px] text-stone-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
<span
className="inline-flex h-3.5 w-3.5 flex-none items-center justify-center rounded-full text-[7px] font-semibold text-white"
style={{ backgroundColor: memberColor(member.id) }}>
{member.name.charAt(0).toUpperCase()}
</span>
<span className="max-w-[7rem] truncate">{member.name}</span>
<span
className={`h-1.5 w-1.5 flex-none rounded-full ${MEMBER_STATUS_DOT[member.memberStatus]}`}
/>
</span>
))}
</div>
</div>
</div>
);
}
@@ -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>): 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<string, AgentTeamTask>([
['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<string, AgentTeamTask>();
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(<TeamTaskBoard tasks={[]} members={members} />);
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(
<TeamTaskBoard
tasks={[task({ id: 't1', title: 'Owned', ownerMemberId: 'm1', claimedByMemberId: 'm1' })]}
members={members}
/>
);
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(
<TeamTaskBoard
tasks={[task({ id: 't1', ownerMemberId: 'm1', claimedByMemberId: 'm2' })]}
members={members}
/>
);
expect(screen.getByText('intelligence.teams.pickedUpBy')).toBeInTheDocument();
});
it('marks an unowned task as unclaimed', () => {
render(<TeamTaskBoard tasks={[task({ id: 't1' })]} members={members} />);
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(<TeamTaskBoard tasks={tasks} members={members} />);
// 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(
<TeamTaskBoard tasks={[task({ gateStatus: 'passed' })]} members={members} />
);
expect(screen.getByText('intelligence.teams.gate.passed')).toBeInTheDocument();
rerender(<TeamTaskBoard tasks={[task({ gateStatus: 'weird-state' })]} members={members} />);
expect(screen.getByText('intelligence.teams.gate.label')).toBeInTheDocument();
});
it('places a task in the column matching its status', () => {
render(
<TeamTaskBoard
tasks={[task({ id: 't1', title: 'Blocked one', status: 'blocked' })]}
members={members}
/>
);
expect(screen.getByText('Blocked one')).toBeInTheDocument();
});
});
@@ -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<AgentTeamTaskStatus, string> = {
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<string, string> = {
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<string, string> = {
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<string, AgentTeamTask>): 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<AgentTeamTaskStatus, AgentTeamTask[]>
);
for (const task of [...tasks].sort((a, b) => a.orderIndex - b.orderIndex)) {
(cardsByStatus[task.status] ?? cardsByStatus.todo).push(task);
}
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{COLUMN_DEFS.map(column => {
const cards = cardsByStatus[column.status];
const accentClass = COLUMN_ACCENT[column.status];
return (
<section
key={column.status}
className={`min-w-0 rounded-lg bg-stone-50 p-2 dark:bg-neutral-800/60 ${accentClass}`}>
<div className="mb-2 flex items-center justify-between gap-2">
<h5 className="truncate text-[11px] font-medium text-stone-600 dark:text-neutral-300">
{t(column.labelKey)}
</h5>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{cards.length}
</span>
</div>
<div className="space-y-2">
{cards.length === 0 ? (
<p className="py-2 text-center text-[10px] text-stone-400 dark:text-neutral-600">
{t('intelligence.teams.emptyColumn')}
</p>
) : (
cards.map(task => (
<TeamTaskCard
key={task.id}
task={task}
memberById={memberById}
unmet={unmetDepCount(task, taskById)}
/>
))
)}
</div>
</section>
);
})}
</div>
);
}
function TeamTaskCard({
task,
memberById,
unmet,
}: {
task: AgentTeamTask;
memberById: Map<string, AgentTeamMember>;
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 (
<article
style={borderStyle}
className="rounded-lg border border-stone-200 bg-white px-2.5 py-2 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
<p className="break-words text-xs font-medium leading-snug text-stone-800 dark:text-neutral-100">
{task.title}
</p>
{showClaimer && (
<p className="mt-0.5 text-[10px] text-stone-500 dark:text-neutral-400">
{t('intelligence.teams.pickedUpBy').replace('{name}', claimer.name)}
</p>
)}
<div className="mt-1.5 flex flex-wrap items-center gap-1">
{owner ? (
<span
style={{ backgroundColor: memberTint(owner.id), color: memberColor(owner.id) }}
className="inline-flex max-w-full items-center gap-1 rounded px-1.5 py-0.5 text-[10px]">
<span
className="inline-flex h-3 w-3 flex-none items-center justify-center rounded-full text-[7px] font-semibold text-white"
style={{ backgroundColor: memberColor(owner.id) }}>
{owner.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{owner.name}</span>
</span>
) : (
<span className="rounded bg-stone-100 px-1.5 py-0.5 text-[10px] italic text-stone-400 dark:bg-neutral-800 dark:text-neutral-500">
{t('intelligence.teams.unclaimed')}
</span>
)}
{unmet > 0 && (
<span
title={t('intelligence.teams.depLockTitle').replace('{count}', String(unmet))}
className="inline-flex items-center gap-1 rounded bg-stone-100 px-1.5 py-0.5 text-[10px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-300">
<LuLock className="h-2.5 w-2.5" />
{unmet}
</span>
)}
<GateBadge gateStatus={task.gateStatus} gateReason={task.gateReason} />
{task.evidence.length > 0 && (
<span className="inline-flex items-center gap-1 rounded bg-sky-50 px-1.5 py-0.5 text-[10px] text-sky-700 dark:bg-sky-500/10 dark:text-sky-200">
<LuCircleCheck className="h-2.5 w-2.5" />
{task.evidence.length}
</span>
)}
</div>
{task.objective && (
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
{task.objective}
</p>
)}
</article>
);
}
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 (
<span
title={gateReason ?? undefined}
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] ${style}`}>
<LuShieldCheck className="h-2.5 w-2.5" />
{label}
</span>
);
}
@@ -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`);
});
});
@@ -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}`;
}
+36
View File
@@ -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': 'المصدر: استقبال مصدر المهام',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+36
View File
@@ -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}',
+37
View File
@@ -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}',
+36
View File
@@ -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': '来源:任务来源接收',
+6
View File
@@ -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' && <IntelligenceAgentWorkTab />}
{activeTab === 'teams' && <IntelligenceTeamsTab />}
{activeTab === 'agents' && <IntelligenceAgentsTab />}
{activeTab === 'workflows' && <WorkflowsTab />}
+125
View File
@@ -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' },
});
});
});
+197
View File
@@ -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<string, unknown>;
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<AgentTeam[]> => {
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<TeamView | null> => {
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<TeamMessage[]> => {
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;
},
};