mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -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
|
||||
* (≈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<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}`;
|
||||
}
|
||||
@@ -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': 'المصدر: استقبال مصدر المهام',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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,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 />}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user