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:
parent
db08b0ae9e
commit
71143db3ac
21 changed files with 583 additions and 90 deletions
|
|
@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
89
docs/research/lead-thought-msg-scroll.md
Normal file
89
docs/research/lead-thought-msg-scroll.md
Normal 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 |
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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}:`)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function unescapeCrossTeamAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue