feat: update cross-team messaging protocol and enhance team provisioning

- Refactored cross-team message formatting to use a canonical XML-like tag structure for improved clarity and consistency.
- Introduced new attributes for cross-team messages, including 'from', 'depth', 'conversationId', and 'replyToConversationId'.
- Enhanced TeamProvisioningService to manage pending cross-team reply expectations, ensuring proper message handling and relay.
- Updated UI components to reflect changes in cross-team messaging, including stripping metadata from displayed messages.
- Added tests to validate new cross-team message handling and ensure proper functionality across services.
This commit is contained in:
iliya 2026-03-10 14:48:55 +02:00
parent db08b0ae9e
commit 71143db3ac
21 changed files with 583 additions and 90 deletions

View file

@ -1,19 +1,38 @@
// Cross-team message protocol constants.
// Mirror of src/shared/constants/crossTeam.ts — keep in sync.
const CROSS_TEAM_PREFIX_TAG = 'Cross-team from';
const CROSS_TEAM_TAG_NAME = 'cross-team';
const CROSS_TEAM_ATTR_FROM = 'from';
const CROSS_TEAM_ATTR_DEPTH = 'depth';
const CROSS_TEAM_ATTR_CONVERSATION_ID = 'conversationId';
const CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID = 'replyToConversationId';
const CROSS_TEAM_SOURCE = 'cross_team';
const CROSS_TEAM_SENT_SOURCE = 'cross_team_sent';
function escapeCrossTeamAttribute(value) {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function formatCrossTeamPrefix(from, chainDepth, meta) {
const parts = [`${CROSS_TEAM_PREFIX_TAG} ${from}`, `depth:${chainDepth}`];
const attrs = [
`${CROSS_TEAM_ATTR_FROM}="${escapeCrossTeamAttribute(from)}"`,
`${CROSS_TEAM_ATTR_DEPTH}="${String(chainDepth)}"`,
];
if (meta && meta.conversationId) {
parts.push(`conversation:${meta.conversationId}`);
attrs.push(
`${CROSS_TEAM_ATTR_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.conversationId)}"`
);
}
if (meta && meta.replyToConversationId) {
parts.push(`replyTo:${meta.replyToConversationId}`);
attrs.push(
`${CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.replyToConversationId)}"`
);
}
return `[${parts.join(' | ')}]`;
return `<${CROSS_TEAM_TAG_NAME} ${attrs.join(' ')} />`;
}
function formatCrossTeamText(from, chainDepth, text, meta) {
@ -21,7 +40,7 @@ function formatCrossTeamText(from, chainDepth, text, meta) {
}
module.exports = {
CROSS_TEAM_PREFIX_TAG,
CROSS_TEAM_TAG_NAME,
CROSS_TEAM_SOURCE,
CROSS_TEAM_SENT_SOURCE,
formatCrossTeamPrefix,

View file

@ -0,0 +1,89 @@
## Findings: реверс порядка lead thoughts (свежие вверху)
### Затронутые файлы
| Файл | Что менять |
|------|------------|
| `LeadThoughtsGroup.tsx` | Основной компонент. Строка 471: `chronologicalThoughts = [...thoughts].reverse()` — убрать reverse (thoughts уже newest-first). Логика автоскрола внутри `LeadThoughtsGroupRow` (строки 570-658). |
| `ActivityTimeline.tsx` | Pinned thought group (строка 322). Порядок messages — newest-first (desc), thoughts группируются через `groupTimelineItems()`. |
| `collapseState.ts` | `findNewestMessageIndex()` ищет первый `type: 'message'` — при реверсе внутри группы не затронут. |
| `AnimatedHeightReveal.tsx` | Не требует изменений — анимирует высоту, а не направление. |
### Архитектура текущего скрола thoughts
**LeadThoughtsGroupRow** имеет свой собственный скрол-контейнер (строки 780-807):
- `scrollRef` — div с `maxHeight: 200px`, `overflowY: auto`
- `contentRef` — внутренний div с thoughts
- Автоскрол к **низу** (`queueScrollSync('bottom')`) — потому что newest внизу
- `isUserScrolledUpRef` — трекает, отскроллил ли юзер вверх
- `distanceFromBottomRef` — сохраняет позицию для `preserve` mode
- `handleScroll` (строка 652) — обновляет `isUserScrolledUpRef` через `AUTO_SCROLL_THRESHOLD = 30px`
- `handleCollapse` (строка 661) — при Show Less скроллит к `scrollHeight` (к низу)
- `syncScrollableBody` (строка 598) — оркестрирует: force bottom / preserve / noop
**Рендеринг** (строка 795): `chronologicalThoughts.map()` — oldest-first, newest в конце. Анимация `shouldAnimate` только для последнего (newest).
### Что нужно изменить для реверса
1. **Убрать `.reverse()` на строке 471** — thoughts уже newest-first, рендерить как есть
2. **Перевернуть автоскрол**: вместо scroll-to-bottom → scroll-to-top (`scrollTop = 0`)
3. **Анимация нового thought**: `shouldAnimate` для `idx === 0` (вместо `idx === chronologicalThoughts.length - 1`)
4. **`isUserScrolledUpRef`** → переименовать в `isUserScrolledDownRef`, проверять расстояние от **верха** (`scrollTop > threshold`)
5. **`queueScrollSync`** — `mode: 'top'` вместо `'bottom'` (`scrollTop = 0`), preserve mode: `scrollTop = distanceFromTopRef.current`
6. **Show More кнопка** — сейчас внизу (ChevronDown). При реверсе: newest вверху, кнопка "Show more" нужна внизу для загрузки старых thoughts — по сути остаётся на месте
7. **Show Less** (`handleCollapse`) — скроллит к верху (`scrollTop = 0`) вместо `scrollHeight`
8. **Divider timestamps** (строка 355-360): `showDivider` при `idx > 0` — работает корректно и при реверсе
### Edge Cases
1. **Новый thought приходит во время скрола вниз к старым** — если юзер прокрутил вниз (к старым), новый thought добавляется вверху. Нужно сохранить `scrollTop` позицию. Решение: `preserve` mode отслеживает `distanceFromTop` вместо `distanceFromBottom`.
2. **Первый thought в пустой группе** — скролл не нужен (нет overflow), анимация fade-in для первого элемента.
3. **Переход collapsed → expanded**`setExpanded(true)` убирает maxHeight. При реверсе scrollTop=0 уже наверху, без скачков. Проблем нет.
4. **Expanded → collapsed (Show Less)** — сейчас `handleCollapse` скроллит к scrollHeight. При реверсе → скроллить к 0. Плюс `scrollIntoView` на контейнер — остаётся как есть.
5. **Real-time streaming** — thoughts приходят через InboxMessage live updates. Каждый новый thought prepend-ится в начало массива `thoughts[]` (newest-first). При реверсе рендера: новый появляется вверху, анимируется LeadThoughtItem с `shouldAnimate`. Ключевой риск: `AnimatedHeightReveal` wrapper расширяется вниз (grid-template-rows: 0fr→1fr). При insert-е вверху контейнер сдвигает всё вниз → **layout shift**. Mitigation: CSS `overflow-anchor: none` уже стоит (строка 790). Но для items внутри scroll-контейнера нужно `overflow-anchor: auto` на последнем видимом элементе. **Это главный технический риск.**
6. **`getThoughtGroupKey`** (строка 67-70) — использует oldest thought для стабильного ключа. При реверсе rendering порядок меняется, но key остаётся тот же. Проблем нет.
7. **ResizeObserver** (строка 632-637) — наблюдает за contentRef. При реверсе content растёт вверху → ResizeObserver сработает, вызовет syncScrollableBody. Нужно убедиться что `preserve` mode корректно считает offset от верха.
8. **`isBodyVisible` toggle** (collapse mode из ActivityTimeline) — скрывает/показывает body. При реверсе: после re-show нужно скроллить к top (не bottom). Затронуто useLayoutEffect на строке 625-638.
### Аналоги в проекте для референса
- `DisplayItemList.tsx:107` — использует `flex-col-reverse` для newest-first. Простой подход, но не подходит для нашего случая (у нас свой scroll container с автоскролом).
- `CliLogsRichView.tsx:376``[...entries].reverse()` для newest-first порядка.
### Предложенный план реализации
1. В `LeadThoughtsGroupRow`: убрать `chronologicalThoughts` reverse, рендерить `thoughts` напрямую (newest-first)
2. Перевернуть scroll-sync логику: auto-scroll к `scrollTop=0`, preserve через `distanceFromTop`
3. Обновить `shouldAnimate` для `idx === 0`
4. Обновить `handleCollapse``scrollTop = 0`
5. Show More/Show Less кнопки: Show More внизу (для старых thoughts) — без изменений. Show Less — без изменений.
6. Тестировать layout shift при live streaming
### Оценки
**Сложность реализации: 5/10**
- Основная логика сосредоточена в одном файле (LeadThoughtsGroup.tsx)
- Scroll-sync перевернуть — умеренно сложно (6 точек изменения: queueScrollSync, handleScroll, handleCollapse, syncScrollableBody, useLayoutEffect, shouldAnimate)
- Нет зависимости от внешнего useAutoScrollBottom — LeadThoughtsGroupRow имеет свой собственный scroll management
- Не нужно трогать groupTimelineItems() или ActivityTimeline
**Уверенность в оценке: 8/10**
- Код хорошо изолирован — весь scroll management внутри одного компонента
- Единственный серьёзный риск — layout shift при real-time prepend (edge case #5)
- CSS overflow-anchor может не работать идеально для insert-вверх в scroll container
### Оценка рисков
| Риск | Вероятность | Последствие | Mitigation |
|------|-------------|-------------|------------|
| Layout shift при live streaming | Средняя | Визуальные скачки | overflow-anchor + manual scrollTop adjustment |
| Broken Show More/Less | Низкая | UX деградация | Простой фикс scrollTop |
| Regression в collapsed mode | Низкая | Thoughts не видны | Не затрагивает collapse logic |
| Browser inconsistency overflow-anchor | Низкая | Скачки в Safari | Fallback: manual scroll compensation |

View file

@ -112,6 +112,7 @@ export class CrossTeamService {
// 4. Cascade check only for real new deliveries
this.cascadeGuard.check(fromTeam, toTeam, chainDepth);
this.cascadeGuard.record(fromTeam, toTeam);
this.provisioning?.registerPendingCrossTeamReplyExpectation(fromTeam, toTeam, conversationId);
// 5. Inbox write to TARGET team (TeamInboxWriter handles file lock + in-process lock internally)
await this.inboxWriter.sendMessage(toTeam, {
@ -133,8 +134,8 @@ export class CrossTeamService {
// 6. Write "sent" copy to SENDER's inbox so the message appears in their activity
const senderLeadName = (await this.dataService.getLeadMemberName(fromTeam)) ?? 'team-lead';
void this.inboxWriter
.sendMessage(fromTeam, {
try {
await this.inboxWriter.sendMessage(fromTeam, {
member: senderLeadName,
text,
from: 'user',
@ -145,12 +146,13 @@ export class CrossTeamService {
source: CROSS_TEAM_SENT_SOURCE,
conversationId,
replyToConversationId,
})
.catch((e: unknown) => {
logger.warn(
`Failed to write sender copy for ${fromTeam}: ${e instanceof Error ? e.message : String(e)}`
);
});
this.provisioning?.clearPendingCrossTeamReplyExpectation(fromTeam, toTeam, conversationId);
} catch (e: unknown) {
logger.warn(
`Failed to write sender copy for ${fromTeam}: ${e instanceof Error ? e.message : String(e)}`
);
}
// 7. Best-effort relay (if online)
if (this.provisioning?.isTeamAlive(toTeam)) {

View file

@ -600,9 +600,10 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
- Prefer concise messages that state: what you need, why that team is relevant, the expected response, and any task or file references they need.
- Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome.
- Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily.
- If you receive a message that is clearly from another team (for example prefixed with "[${CROSS_TEAM_PREFIX_TAG} ...]"), treat it as an actionable cross-team request and respond to the originating team with "cross_team_send" when a reply, decision, or status update is needed.
- If you receive a message that is clearly from another team (for example prefixed with "<${CROSS_TEAM_PREFIX_TAG} ... />"), treat it as an actionable cross-team request and respond to the originating team with "cross_team_send" when a reply, decision, or status update is needed.
- Cross-team requests may include a stable conversationId in their metadata. When you reply to that thread, preserve the same conversationId and pass replyToConversationId with that same value so the system can correlate the reply reliably.
- If the relay prompt shows explicit cross-team reply metadata/instructions for a message, follow that metadata exactly when calling "cross_team_send".
- Never write protocol markup yourself in message text. Do NOT include "<${CROSS_TEAM_PREFIX_TAG} ... />" or any other metadata wrapper in the visible reply body; send plain user-visible text only.
- When a cross-team request arrives, do NOT appear silent: first emit a brief plain-text status update visible in your own team's Messages/Activity (for example: "Accepted cross-team request from @other-team. Investigating and delegating now."), then do the research, task creation, or delegation work.
- For cross-team work, your canonical progress trail should be team-visible first. Use plain text updates, task comments, and task state changes so your own team can see what is happening.
- Do not wait silently on another team: if cross-team coordination is blocking progress, send the request promptly, then continue any useful local work that does not depend on that answer.
@ -1073,6 +1074,7 @@ function isTransientProbeWarning(warning: string): boolean {
export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
private static readonly PENDING_CROSS_TEAM_REPLY_TTL_MS = 10 * 60 * 1000;
private readonly runs = new Map<string, ProvisioningRun>();
private readonly activeByTeam = new Map<string, string>();
@ -1081,6 +1083,7 @@ export class TeamProvisioningService {
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
private readonly memberInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedMemberInboxMessageIds = new Map<string, Set<string>>();
private readonly pendingCrossTeamFirstReplies = new Map<string, Map<string, number>>();
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
private helpOutputCache: string | null = null;
@ -1285,6 +1288,80 @@ export class TeamProvisioningService {
return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0;
}
private buildCrossTeamConversationKey(otherTeam: string, conversationId: string): string {
return `${otherTeam.trim()}\0${conversationId.trim()}`;
}
private parseCrossTeamTargetTeam(value: string | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith('cross-team:')) {
const teamName = trimmed.slice('cross-team:'.length).trim();
return TEAM_NAME_PATTERN.test(teamName) ? teamName : null;
}
const dot = trimmed.indexOf('.');
if (dot <= 0) return null;
const teamName = trimmed.slice(0, dot).trim();
return TEAM_NAME_PATTERN.test(teamName) ? teamName : null;
}
private getCrossTeamSourceTeam(value: string | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
const dot = trimmed.indexOf('.');
if (dot <= 0) return null;
const teamName = trimmed.slice(0, dot).trim();
return TEAM_NAME_PATTERN.test(teamName) ? teamName : null;
}
registerPendingCrossTeamReplyExpectation(
teamName: string,
otherTeam: string,
conversationId: string
): void {
const normalizedTeam = teamName.trim();
const normalizedOtherTeam = otherTeam.trim();
const normalizedConversationId = conversationId.trim();
if (!normalizedTeam || !normalizedOtherTeam || !normalizedConversationId) return;
const teamMap =
this.pendingCrossTeamFirstReplies.get(normalizedTeam) ?? new Map<string, number>();
teamMap.set(
this.buildCrossTeamConversationKey(normalizedOtherTeam, normalizedConversationId),
Date.now()
);
this.pendingCrossTeamFirstReplies.set(normalizedTeam, teamMap);
}
clearPendingCrossTeamReplyExpectation(
teamName: string,
otherTeam: string,
conversationId: string
): void {
const teamMap = this.pendingCrossTeamFirstReplies.get(teamName.trim());
if (!teamMap) return;
teamMap.delete(this.buildCrossTeamConversationKey(otherTeam, conversationId));
if (teamMap.size === 0) {
this.pendingCrossTeamFirstReplies.delete(teamName.trim());
}
}
private getPendingCrossTeamReplyExpectationKeys(teamName: string): Set<string> {
const teamMap = this.pendingCrossTeamFirstReplies.get(teamName.trim());
if (!teamMap) return new Set<string>();
const cutoff = Date.now() - TeamProvisioningService.PENDING_CROSS_TEAM_REPLY_TTL_MS;
for (const [key, createdAt] of teamMap.entries()) {
if (createdAt < cutoff) {
teamMap.delete(key);
}
}
if (teamMap.size === 0) {
this.pendingCrossTeamFirstReplies.delete(teamName.trim());
return new Set<string>();
}
return new Set(teamMap.keys());
}
private persistSentMessage(teamName: string, message: InboxMessage): void {
try {
createController({
@ -2836,16 +2913,43 @@ export class TeamProvisioningService {
if (unread.length === 0) return 0;
const outboundCrossTeamConversations = new Set(
leadInboxMessages.flatMap((message) => {
if (message.source !== CROSS_TEAM_SENT_SOURCE) return [];
const latestOutboundByConversation = new Map<string, number>();
const latestReadInboundByConversation = new Map<string, number>();
for (const message of leadInboxMessages) {
const timestampMs = Date.parse(message.timestamp);
if (!Number.isFinite(timestampMs)) continue;
if (message.source === CROSS_TEAM_SENT_SOURCE) {
const conversationId = message.conversationId?.trim();
const targetTeam =
typeof message.to === 'string' ? message.to.split('.', 1)[0]?.trim() : '';
if (!conversationId || !targetTeam) return [];
return [`${conversationId}\0${targetTeam}`];
})
const targetTeam = this.parseCrossTeamTargetTeam(message.to);
if (!conversationId || !targetTeam) continue;
const key = this.buildCrossTeamConversationKey(targetTeam, conversationId);
latestOutboundByConversation.set(
key,
Math.max(latestOutboundByConversation.get(key) ?? 0, timestampMs)
);
continue;
}
if (message.source === CROSS_TEAM_SOURCE && message.read) {
const conversationId =
message.replyToConversationId?.trim() ??
message.conversationId?.trim() ??
parseCrossTeamPrefix(message.text)?.conversationId;
const sourceTeam = this.getCrossTeamSourceTeam(message.from);
if (!conversationId || !sourceTeam) continue;
const key = this.buildCrossTeamConversationKey(sourceTeam, conversationId);
latestReadInboundByConversation.set(
key,
Math.max(latestReadInboundByConversation.get(key) ?? 0, timestampMs)
);
}
}
const pendingHistoricalReplies = new Set(
Array.from(latestOutboundByConversation.entries())
.filter(([key, sentAtMs]) => sentAtMs > (latestReadInboundByConversation.get(key) ?? 0))
.map(([key]) => key)
);
const pendingTransientReplies = this.getPendingCrossTeamReplyExpectationKeys(teamName);
const matchedTransientReplyKeys = new Set<string>();
const isCrossTeamReplyToOwnOutbound = (message: InboxMessage): boolean => {
if (message.source !== CROSS_TEAM_SOURCE) return false;
@ -2854,9 +2958,17 @@ export class TeamProvisioningService {
message.conversationId?.trim() ??
parseCrossTeamPrefix(message.text)?.conversationId;
if (!conversationId) return false;
const sourceTeam = message.from.includes('.') ? message.from.split('.', 1)[0]?.trim() : '';
const sourceTeam = this.getCrossTeamSourceTeam(message.from);
if (!sourceTeam) return false;
return outboundCrossTeamConversations.has(`${conversationId}\0${sourceTeam}`);
const key = this.buildCrossTeamConversationKey(sourceTeam, conversationId);
if (pendingHistoricalReplies.has(key)) {
return true;
}
if (pendingTransientReplies.has(key)) {
matchedTransientReplyKeys.add(key);
return true;
}
return false;
};
// Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages.
@ -2876,6 +2988,12 @@ export class TeamProvisioningService {
} catch {
// best-effort
}
for (const key of matchedTransientReplyKeys) {
const [otherTeam, conversationId] = key.split('\0');
if (otherTeam && conversationId) {
this.clearPendingCrossTeamReplyExpectation(teamName, otherTeam, conversationId);
}
}
}
const actionableUnread = unread.filter(
@ -4490,6 +4608,7 @@ export class TeamProvisioningService {
this.activeByTeam.delete(run.teamName);
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
this.pendingCrossTeamFirstReplies.delete(run.teamName);
run.activeCrossTeamReplyHints = [];
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
if (key.startsWith(`${run.teamName}:`)) {

View file

@ -1,9 +1,9 @@
import { useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { Bell, HardDrive, Server, Settings, Wrench } from 'lucide-react';
import { Bell, Server, Settings, Wrench } from 'lucide-react';
export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'advanced';
export type SettingsSection = 'general' | 'connection' | 'notifications' | 'advanced';
interface SettingsTabsProps {
activeSection: SettingsSection;
@ -20,7 +20,6 @@ interface TabConfig {
const tabs: TabConfig[] = [
{ id: 'general', label: 'General', icon: Settings },
{ id: 'connection', label: 'Connection', icon: Server, electronOnly: true },
{ id: 'workspace', label: 'Workspaces', icon: HardDrive, electronOnly: true },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'advanced', label: 'Advanced', icon: Wrench },
];
@ -37,7 +36,7 @@ export const SettingsTabs = ({
);
return (
<div className="inline-flex gap-1 border-b" style={{ borderColor: 'var(--color-border)' }}>
<div className="flex gap-1 border-b" style={{ borderColor: 'var(--color-border)' }}>
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeSection === tab.id;

View file

@ -14,7 +14,6 @@ import {
ConnectionSection,
GeneralSection,
NotificationsSection,
WorkspaceSection,
} from './sections';
import { type SettingsSection, SettingsTabs } from './SettingsTabs';
@ -133,8 +132,6 @@ export const SettingsView = (): React.JSX.Element | null => {
{activeSection === 'connection' && <ConnectionSection />}
{activeSection === 'workspace' && <WorkspaceSection />}
{activeSection === 'notifications' && (
<NotificationsSection
safeConfig={safeConfig}

View file

@ -6,6 +6,7 @@
* - Configuring SSH connection (host, port, username, auth)
* - SSH config host alias combobox with auto-fill
* - Testing and connecting to remote hosts
* - Workspace profiles (via embedded WorkspaceSection)
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@ -18,6 +19,8 @@ import { SettingRow } from '../components/SettingRow';
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
import { SettingsSelect } from '../components/SettingsSelect';
import { WorkspaceSection } from './WorkspaceSection';
import type {
ClaudeRootInfo,
SshAuthMethod,
@ -515,6 +518,11 @@ export const ConnectionSection = (): React.JSX.Element => {
</div>
</div>
)}
{/* Workspace Profiles */}
<div className="border-t pt-6" style={{ borderColor: 'var(--color-border)' }}>
<WorkspaceSection />
</div>
</div>
);
};

View file

@ -14,6 +14,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react';
@ -383,22 +384,34 @@ export const WorkspaceSection = (): React.JSX.Element => {
>
{profile.authMethod}
</span>
<button
onClick={() => setEditingId(profile.id)}
className="shrink-0 rounded p-1 transition-colors hover:bg-surface-raised"
style={{ color: 'var(--color-text-muted)' }}
title="Edit profile"
>
<Edit2 className="size-3.5" />
</button>
<button
onClick={() => void handleDelete(profile.id)}
className="shrink-0 rounded p-1 transition-colors hover:bg-surface-raised"
style={{ color: 'var(--color-text-muted)' }}
title="Delete profile"
>
<Trash2 className="size-3.5" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setEditingId(profile.id)}
className="shrink-0 rounded p-1 transition-colors hover:bg-surface-raised"
style={{ color: 'var(--color-text-muted)' }}
>
<Edit2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Edit profile
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => void handleDelete(profile.id)}
className="shrink-0 rounded p-1 transition-colors hover:bg-surface-raised"
style={{ color: 'var(--color-text-muted)' }}
>
<Trash2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Delete profile
</TooltipContent>
</Tooltip>
</div>
)
)}

View file

@ -227,6 +227,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
startTask,
deleteTeam,
openTeamsTab,
closeTab,
sendingMessage,
sendMessageError,
lastSendMessageResult,
@ -270,6 +271,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
startTask: s.startTask,
deleteTeam: s.deleteTeam,
openTeamsTab: s.openTeamsTab,
closeTab: s.closeTab,
sendingMessage: s.sendingMessage,
sendMessageError: s.sendMessageError,
lastSendMessageResult: s.lastSendMessageResult,
@ -854,12 +856,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
void (async () => {
try {
await deleteTeam(teamName);
if (tabId) closeTab(tabId);
openTeamsTab();
} catch {
// error is shown via store
}
})();
}, [teamName, deleteTeam, openTeamsTab]);
}, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]);
const handleCreateTask = (
subject: string,

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { Fragment, useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
@ -265,13 +265,13 @@ export function linkifyTaskIdsInMarkdown(text: string): string {
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => {
const match = /^#([A-Za-z0-9-]+)$/.exec(part);
if (!match) return <span key={i}>{part}</span>;
if (!match) return <Fragment key={i}>{part}</Fragment>;
const taskId = match[1];
return (
<TaskTooltip key={i} taskId={taskId}>
<button
type="button"
className="cursor-pointer font-medium text-blue-600 hover:underline dark:text-blue-400"
className="inline cursor-pointer font-medium text-blue-600 hover:underline dark:text-blue-400"
onClick={(e) => {
e.stopPropagation();
onClick(taskId);
@ -365,7 +365,8 @@ export const ActivityItem = ({
if (structured) return null;
let stripped = stripAgentBlocks(message.text).trim();
if (!stripped) return null; // All content was agent-only blocks → show summary instead
// Strip cross-team prefix (e.g. "[Cross-team from team.lead | depth:0]\n") — kept in stored text for CLI agents
// Strip cross-team metadata tag (e.g. `<cross-team from="team.lead" depth="0" />\n`)
// — kept in stored text for CLI agents / durable artifacts.
if (isCrossTeamAny) {
stripped = stripCrossTeamPrefix(stripped);
}
@ -569,7 +570,7 @@ export const ActivityItem = ({
) : null}
{/* Summary */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
<span className="min-w-0 flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText}
</span>

View file

@ -8,7 +8,7 @@ import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
import { cn } from '@renderer/lib/utils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import {
CheckCircle2,
ClipboardList,
@ -106,12 +106,12 @@ const COLUMNS: { id: KanbanColumnId; title: string }[] = [
];
function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId | null {
const explicit = getTaskKanbanColumn({
reviewState: task.reviewState,
kanbanColumn: kanbanState.tasks[task.id]?.column,
});
if (explicit) {
return explicit;
// Kanban state is authoritative for review/approved placement.
// When clearKanban removes a task, the entry is deleted — so we must NOT
// fall back to task.reviewState, otherwise the task reappears in approved/review.
const kanbanEntry = kanbanState.tasks[task.id];
if (kanbanEntry?.column) {
return kanbanEntry.column;
}
if (task.status === 'pending') {

View file

@ -66,7 +66,6 @@ export const MessageComposer = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
const [actionMode, setActionMode] = useState<ActionMode>('do');
// Cross-team state
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
@ -139,14 +138,35 @@ export const MessageComposer = ({
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canDelegate = isCrossTeam || isLeadRecipient;
// Auto-select delegate when lead recipient changes, reset when non-lead
const { actionMode, setActionMode, isLoaded: draftLoaded } = draft;
// Auto-select delegate when lead recipient is chosen by the user.
// Wait until draft is restored from IndexedDB (draftLoaded) before running,
// so we don't overwrite the persisted actionMode during initialization.
// After draft loads, only auto-switch on subsequent recipient changes.
const isInitializedRef = useRef(false);
const prevIsLeadRef = useRef(isLeadRecipient);
useEffect(() => {
if (!draftLoaded) return;
// On first run after load, just record the baseline — don't overwrite
if (!isInitializedRef.current) {
isInitializedRef.current = true;
prevIsLeadRef.current = isLeadRecipient;
return;
}
// Only react when isLeadRecipient actually changes
if (isLeadRecipient === prevIsLeadRef.current) return;
prevIsLeadRef.current = isLeadRecipient;
if (isLeadRecipient) {
setActionMode('delegate');
} else {
setActionMode((prev) => (prev === 'delegate' ? 'do' : prev));
} else if (actionMode === 'delegate') {
setActionMode('do');
}
}, [isLeadRecipient]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLeadRecipient, draftLoaded]);
// NOTE: lead context ring disabled — usage formula is inaccurate
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
// const leadContext = useStore((s) =>

View file

@ -25,7 +25,7 @@ import {
} from '@renderer/utils/attachmentUtils';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { AttachmentPayload } from '@shared/types';
import type { AttachmentPayload, AgentActionMode } from '@shared/types';
// ---------------------------------------------------------------------------
// Types
@ -52,6 +52,10 @@ export interface UseComposerDraftResult {
handlePaste: (event: React.ClipboardEvent) => void;
handleDrop: (event: React.DragEvent) => void;
// Action mode
actionMode: AgentActionMode;
setActionMode: (mode: AgentActionMode) => void;
// Status
isSaved: boolean;
isLoaded: boolean;
@ -75,6 +79,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
const [chips, setChipsState] = useState<InlineChip[]>([]);
const [attachments, setAttachmentsState] = useState<AttachmentPayload[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [actionMode, setActionModeState] = useState<AgentActionMode>('do');
const [isSaved, setIsSaved] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
@ -82,6 +87,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
const textRef = useRef('');
const chipsRef = useRef<InlineChip[]>([]);
const attachmentsRef = useRef<AttachmentPayload[]>([]);
const actionModeRef = useRef<AgentActionMode>('do');
const teamNameRef = useRef(teamName);
const mountedRef = useRef(true);
@ -115,6 +121,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
text: textRef.current,
chips: chipsRef.current,
attachments: attachmentsRef.current,
actionMode: actionModeRef.current,
updatedAt: Date.now(),
};
}, []);
@ -176,9 +183,11 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
textRef.current = snap.text;
chipsRef.current = snap.chips;
attachmentsRef.current = snap.attachments;
actionModeRef.current = snap.actionMode ?? 'do';
setTextState(snap.text);
setChipsState(snap.chips);
setAttachmentsState(snap.attachments);
setActionModeState(snap.actionMode ?? 'do');
}, []);
// ---------------------------------------------------------------------------
@ -287,6 +296,21 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
[scheduleSave]
);
// ---------------------------------------------------------------------------
// Action mode
// ---------------------------------------------------------------------------
const setActionMode = useCallback(
(mode: AgentActionMode) => {
userTouchedRef.current = true;
actionModeRef.current = mode;
setActionModeState(mode);
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
);
// ---------------------------------------------------------------------------
// Attachments
// ---------------------------------------------------------------------------
@ -420,10 +444,12 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
textRef.current = '';
chipsRef.current = [];
attachmentsRef.current = [];
actionModeRef.current = 'do';
setTextState('');
setChipsState([]);
setAttachmentsState([]);
setActionModeState('do');
setAttachmentError(null);
setIsSaved(false);
@ -445,6 +471,8 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
clearAttachmentError,
handlePaste,
handleDrop,
actionMode,
setActionMode,
isSaved,
isLoaded,
clearDraft,

View file

@ -9,7 +9,7 @@
import { del, get, set } from 'idb-keyval';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { AttachmentPayload } from '@shared/types';
import type { AttachmentPayload, AgentActionMode } from '@shared/types';
// ---------------------------------------------------------------------------
// Types
@ -24,6 +24,7 @@ export interface ComposerDraftSnapshot {
text: string;
chips: InlineChip[];
attachments: AttachmentPayload[];
actionMode?: AgentActionMode;
updatedAt: number;
}

View file

@ -692,11 +692,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return;
}
// Clear stale data immediately to prevent flash of previous team's content
const prev = get().selectedTeamName;
// Stale-while-revalidate: keep previous data visible while loading new team.
// Skeleton only shows on first load (when data is null).
// Data is atomically replaced when the new team's data arrives.
set({
selectedTeamName: teamName,
selectedTeamData: prev !== teamName ? null : get().selectedTeamData,
selectedTeamLoading: true,
selectedTeamError: null,
reviewActionError: null,

View file

@ -2,8 +2,13 @@
// Single source of truth for the cross-team message prefix format.
// Used by: CrossTeamService (main), crossTeam.js (controller), ActivityItem (renderer), tests.
/** Prefix tag that wraps cross-team metadata in stored message text. */
export const CROSS_TEAM_PREFIX_TAG = 'Cross-team from';
/** Canonical metadata tag written before stored cross-team message text. */
export const CROSS_TEAM_TAG_NAME = 'cross-team';
export const CROSS_TEAM_ATTR_FROM = 'from';
export const CROSS_TEAM_ATTR_DEPTH = 'depth';
export const CROSS_TEAM_ATTR_CONVERSATION_ID = 'conversationId';
export const CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID = 'replyToConversationId';
export const CROSS_TEAM_PREFIX_TAG = CROSS_TEAM_TAG_NAME;
export interface CrossTeamPrefixMeta {
conversationId?: string;
@ -15,23 +20,58 @@ export interface ParsedCrossTeamPrefix extends CrossTeamPrefixMeta {
chainDepth: number;
}
function escapeCrossTeamAttribute(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function unescapeCrossTeamAttribute(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function parseCrossTeamAttributes(raw: string): Map<string, string> {
const attrs = new Map<string, string>();
const matches = raw.matchAll(/([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g);
for (const match of matches) {
const key = match[1]?.trim();
const value = match[2];
if (!key || value == null) continue;
attrs.set(key, unescapeCrossTeamAttribute(value));
}
return attrs;
}
/**
* Build the full prefix line:
* `[Cross-team from team.member | depth:N | conversation:abc | replyTo:def]`
* `<cross-team from="team.member" depth="0" conversationId="abc" replyToConversationId="def" />`
*/
export function formatCrossTeamPrefix(
from: string,
chainDepth: number,
meta?: CrossTeamPrefixMeta
): string {
const parts = [`${CROSS_TEAM_PREFIX_TAG} ${from}`, `depth:${chainDepth}`];
const attrs = [
`${CROSS_TEAM_ATTR_FROM}="${escapeCrossTeamAttribute(from)}"`,
`${CROSS_TEAM_ATTR_DEPTH}="${String(chainDepth)}"`,
];
if (meta?.conversationId) {
parts.push(`conversation:${meta.conversationId}`);
attrs.push(
`${CROSS_TEAM_ATTR_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.conversationId)}"`
);
}
if (meta?.replyToConversationId) {
parts.push(`replyTo:${meta.replyToConversationId}`);
attrs.push(
`${CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.replyToConversationId)}"`
);
}
return `[${parts.join(' | ')}]`;
return `<${CROSS_TEAM_TAG_NAME} ${attrs.join(' ')} />`;
}
/** Format the full message text with prefix + body. */
@ -45,26 +85,27 @@ export function formatCrossTeamText(
}
/**
* Regex that matches the cross-team prefix line at the start of a message.
* Compatible with legacy rows that only contain `depth`.
* Regex that matches the canonical cross-team metadata tag at the start of a message.
*/
export const CROSS_TEAM_PREFIX_RE =
/^\[Cross-team from (?<from>[^\]|]+?) \| depth:(?<depth>\d+)(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
export const CROSS_TEAM_PREFIX_RE = new RegExp(
`^<${CROSS_TEAM_TAG_NAME}\\s+(?<attrs>[^>]*?)\\s*\\/>\\n?`
);
/** Parse metadata from a cross-team prefix line. */
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
const match = text.match(CROSS_TEAM_PREFIX_RE);
if (!match?.groups) return null;
const from = match.groups.from?.trim();
const chainDepth = Number.parseInt(match.groups.depth ?? '', 10);
const attrs = parseCrossTeamAttributes(match.groups.attrs ?? '');
const from = attrs.get(CROSS_TEAM_ATTR_FROM)?.trim();
const chainDepth = Number.parseInt(attrs.get(CROSS_TEAM_ATTR_DEPTH) ?? '', 10);
if (!from || !Number.isFinite(chainDepth)) return null;
return {
from,
chainDepth,
conversationId: match.groups.conversationId?.trim() || undefined,
replyToConversationId: match.groups.replyToConversationId?.trim() || undefined,
conversationId: attrs.get(CROSS_TEAM_ATTR_CONVERSATION_ID)?.trim() || undefined,
replyToConversationId: attrs.get(CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID)?.trim() || undefined,
};
}

View file

@ -56,6 +56,8 @@ describe('CrossTeamService', () => {
isTeamAlive: ReturnType<typeof vi.fn>;
relayLeadInboxMessages: ReturnType<typeof vi.fn>;
resolveCrossTeamReplyMetadata: ReturnType<typeof vi.fn>;
registerPendingCrossTeamReplyExpectation: ReturnType<typeof vi.fn>;
clearPendingCrossTeamReplyExpectation: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@ -73,6 +75,8 @@ describe('CrossTeamService', () => {
isTeamAlive: vi.fn().mockReturnValue(false),
relayLeadInboxMessages: vi.fn().mockResolvedValue(0),
resolveCrossTeamReplyMetadata: vi.fn().mockReturnValue(null),
registerPendingCrossTeamReplyExpectation: vi.fn(),
clearPendingCrossTeamReplyExpectation: vi.fn(),
};
service = new CrossTeamService(
@ -189,6 +193,35 @@ describe('CrossTeamService', () => {
expect(provisioning.relayLeadInboxMessages).toHaveBeenCalledWith('team-b');
});
it('writes sender copy before triggering live relay', async () => {
const order: string[] = [];
inboxWriter.sendMessage.mockImplementation(async (teamName: string) => {
order.push(`write:${teamName}`);
return { deliveredToInbox: true, messageId: 'mock-id' };
});
provisioning.registerPendingCrossTeamReplyExpectation.mockImplementation(async () => {
order.push('register:team-a->team-b');
});
provisioning.clearPendingCrossTeamReplyExpectation.mockImplementation(async () => {
order.push('clear:team-a->team-b');
});
provisioning.isTeamAlive.mockReturnValue(true);
provisioning.relayLeadInboxMessages.mockImplementation(async () => {
order.push('relay:team-b');
return 0;
});
await service.send(makeRequest());
expect(order).toEqual([
'register:team-a->team-b',
'write:team-b',
'write:team-a',
'clear:team-a->team-b',
'relay:team-b',
]);
});
it('does not relay when team is offline', async () => {
provisioning.isTeamAlive.mockReturnValue(false);

View file

@ -163,7 +163,7 @@ describe('TeamInboxWriter', () => {
from: 'team-lead',
to: 'team-best.user',
text: 'Hello cross-team',
summary: 'Cross-team reply',
summary: 'Cross-team response',
messageId: 'lead-sendmsg-run-1-123',
timestamp: '2026-03-10T00:33:55.000Z',
source: 'lead_process',
@ -178,7 +178,7 @@ describe('TeamInboxWriter', () => {
from: 'team-lead',
to: 'team-best.user',
text: 'Hello cross-team',
summary: 'Cross-team reply',
summary: 'Cross-team response',
messageId: 'lead-sendmsg-run-1-123',
timestamp: '2026-03-10T00:33:55.000Z',
source: 'lead_process',

View file

@ -518,6 +518,44 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
});
it('strips canonical cross-team tag from outbound cross-team content', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-legacy' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-legacy' }];
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'team-best.user',
content:
'<cross-team from="my-team.team-lead" depth="0" conversationId="conv-legacy" replyToConversationId="conv-legacy" />\nПривет!',
summary: 'Ответ',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
text: 'Привет!',
conversationId: 'conv-legacy',
replyToConversationId: 'conv-legacy',
})
);
});
it('does not push a duplicate live row when cross-team fallback deduplicates', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');

View file

@ -386,7 +386,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
{
from: 'other-team.team-lead',
to: 'team-lead',
text: '[Cross-team from other-team.team-lead | conversation:conv-1 | replyToConversation:conv-1] Reply back to origin.',
text: '<cross-team from="other-team.team-lead" depth="0" conversationId="conv-1" replyToConversationId="conv-1" />\nReply back to origin.',
timestamp: '2026-02-23T10:01:00.000Z',
read: false,
source: 'cross_team',
@ -409,6 +409,86 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(updatedInbox[1]?.messageId).toBe('m-cross-team-reply-1');
});
it('does not relay a fast first reply while outbound sender copy is still pending', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
service.registerPendingCrossTeamReplyExpectation(teamName, 'other-team', 'conv-race');
seedLeadInbox(teamName, [
{
from: 'other-team.team-lead',
to: 'team-lead',
text: '<cross-team from="other-team.team-lead" depth="0" conversationId="conv-race" replyToConversationId="conv-race" />\nFast reply before sender copy.',
timestamp: '2026-02-23T10:01:00.000Z',
read: false,
source: 'cross_team',
messageId: 'm-cross-team-race-1',
conversationId: 'conv-race',
replyToConversationId: 'conv-race',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayLeadInboxMessages(teamName);
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
});
it('relays later follow-up messages after the first reply in a conversation was already received', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'user',
to: 'other-team.team-lead',
text: 'Original outbound request',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'cross_team_sent',
messageId: 'm-cross-team-sent-2',
conversationId: 'conv-followup',
},
{
from: 'other-team.team-lead',
to: 'team-lead',
text: '<cross-team from="other-team.team-lead" depth="0" conversationId="conv-followup" replyToConversationId="conv-followup" />\nFirst answer.',
timestamp: '2026-02-23T10:01:00.000Z',
read: true,
source: 'cross_team',
messageId: 'm-cross-team-first-reply',
conversationId: 'conv-followup',
replyToConversationId: 'conv-followup',
},
{
from: 'other-team.team-lead',
to: 'team-lead',
text: '<cross-team from="other-team.team-lead" depth="0" conversationId="conv-followup" replyToConversationId="conv-followup" />\nCan you confirm one more detail?',
timestamp: '2026-02-23T10:02:00.000Z',
read: false,
source: 'cross_team',
messageId: 'm-cross-team-followup',
conversationId: 'conv-followup',
replyToConversationId: 'conv-followup',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
expect(run?.leadRelayCapture).toBeTruthy();
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'I will answer the follow-up.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
const relayed = await relayPromise;
expect(relayed).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
});
it('relays unread teammate inbox messages through the live team process', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';

View file

@ -5,7 +5,7 @@ import { parseCrossTeamPrefix, stripCrossTeamPrefix } from '@shared/constants/cr
describe('crossTeam protocol helpers', () => {
it('parses canonical cross-team prefix metadata', () => {
const parsed = parseCrossTeamPrefix(
'[Cross-team from dream-team.team-lead | depth:0 | conversation:conv-1 | replyTo:conv-0]\nHello'
'<cross-team from="dream-team.team-lead" depth="0" conversationId="conv-1" replyToConversationId="conv-0" />\nHello'
);
expect(parsed).toEqual({
@ -17,7 +17,9 @@ describe('crossTeam protocol helpers', () => {
});
it('strips canonical prefix from UI text', () => {
expect(stripCrossTeamPrefix('[Cross-team from a.b | depth:0 | conversation:conv-1]\nHello')).toBe(
expect(
stripCrossTeamPrefix('<cross-team from="a.b" depth="0" conversationId="conv-1" />\nHello')
).toBe(
'Hello'
);
});