diff --git a/app/src/components/intelligence/TeamActivityRail.test.tsx b/app/src/components/intelligence/TeamActivityRail.test.tsx
index 3bbb995ea..6db97442e 100644
--- a/app/src/components/intelligence/TeamActivityRail.test.tsx
+++ b/app/src/components/intelligence/TeamActivityRail.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AgentTeamMember, TeamMessage } from '../../services/api/agentTeamApi';
@@ -50,4 +50,45 @@ describe('TeamActivityRail', () => {
screen.getByText('intelligence.teams.activity.toTeam', { exact: false })
).toBeInTheDocument();
});
+
+ it('renders no composer without onSend', () => {
+ render();
+ expect(
+ screen.queryByPlaceholderText('intelligence.teams.composer.placeholder')
+ ).not.toBeInTheDocument();
+ });
+
+ it('sends a broadcast (null recipient) with the typed content and clears the draft', async () => {
+ const onSend = vi.fn().mockResolvedValue(undefined);
+ render();
+ const input = screen.getByPlaceholderText(
+ 'intelligence.teams.composer.placeholder'
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'ship it' } });
+ fireEvent.click(screen.getByLabelText('intelligence.teams.composer.send'));
+ expect(onSend).toHaveBeenCalledWith(null, 'ship it');
+ await waitFor(() => expect(input.value).toBe(''));
+ });
+
+ it('sends to the selected teammate and submits on Enter', () => {
+ const onSend = vi.fn().mockResolvedValue(undefined);
+ render();
+ fireEvent.change(screen.getByLabelText('intelligence.teams.composer.recipient'), {
+ target: { value: 'm2' },
+ });
+ const input = screen.getByPlaceholderText('intelligence.teams.composer.placeholder');
+ fireEvent.change(input, { target: { value: 'your turn' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onSend).toHaveBeenCalledWith('m2', 'your turn');
+ });
+
+ it('does not send blank content', () => {
+ const onSend = vi.fn();
+ render();
+ fireEvent.change(screen.getByPlaceholderText('intelligence.teams.composer.placeholder'), {
+ target: { value: ' ' },
+ });
+ fireEvent.click(screen.getByLabelText('intelligence.teams.composer.send'));
+ expect(onSend).not.toHaveBeenCalled();
+ });
});
diff --git a/app/src/components/intelligence/TeamActivityRail.tsx b/app/src/components/intelligence/TeamActivityRail.tsx
index a0f8b54a1..6571d9697 100644
--- a/app/src/components/intelligence/TeamActivityRail.tsx
+++ b/app/src/components/intelligence/TeamActivityRail.tsx
@@ -13,6 +13,9 @@
* 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 { useState } from 'react';
+import { LuSend } from 'react-icons/lu';
+
import { useT } from '../../lib/i18n/I18nContext';
import type { AgentTeamMember, TeamMessage } from '../../services/api/agentTeamApi';
import { memberColor } from './memberColors';
@@ -20,12 +23,38 @@ import { memberColor } from './memberColors';
interface TeamActivityRailProps {
messages: TeamMessage[];
members: AgentTeamMember[];
+ /**
+ * When provided, renders a composer footer so the user (as the lead) can
+ * address a named teammate, or the whole team when `toMemberId` is `null`.
+ */
+ onSend?: (toMemberId: string | null, content: string) => void | Promise;
+ /** True while a send is in flight (disables the composer). */
+ sending?: boolean;
}
-export function TeamActivityRail({ messages, members }: TeamActivityRailProps) {
+export function TeamActivityRail({ messages, members, onSend, sending }: TeamActivityRailProps) {
const { t } = useT();
const memberById = new Map(members.map(m => [m.id, m]));
+ const [draft, setDraft] = useState('');
+ const [recipient, setRecipient] = useState('');
+
+ const submit = () => {
+ const content = draft.trim();
+ if (!content || sending || !onSend) return;
+ // Clear the draft only on a resolved send; on rejection keep the unsent text
+ // so the user can retry. The empty .catch() handles the rejection (the parent
+ // surfaces the error via its own notice state) and prevents an unhandled
+ // promise rejection from the now-throwing onSend.
+ void Promise.resolve(onSend(recipient === '' ? null : recipient, content))
+ .then(() => {
+ setDraft('');
+ })
+ .catch(() => {
+ /* send failed — draft retained; error surfaced by the parent */
+ });
+ };
+
const nameFor = (id: string | null): string => {
if (!id) return t('intelligence.teams.activity.toTeam');
return memberById.get(id)?.name ?? id;
@@ -72,6 +101,48 @@ export function TeamActivityRail({ messages, members }: TeamActivityRailProps) {
})}
)}
+
+ {onSend && (
+