feat: enhance cross-team messaging and message storage
- Introduced new parameters for cross-team messaging, including CROSS_TEAM_SENT_SOURCE for better tracking of sent messages. - Updated sendCrossTeamMessage function to append sent messages to the message store, ensuring a complete history of communications. - Enhanced tests to validate the new message storage functionality and ensure accurate retrieval of sent messages. - Improved handling of message timestamps and deduplication logic for cross-team communications.
This commit is contained in:
parent
9d2062c8c0
commit
5da9e2372d
16 changed files with 785 additions and 116 deletions
|
|
@ -3,9 +3,14 @@ const path = require('path');
|
|||
const crypto = require('crypto');
|
||||
const { createControllerContext } = require('./context.js');
|
||||
const { withFileLockSync } = require('./fileLock.js');
|
||||
const messageStore = require('./messageStore.js');
|
||||
const cascadeGuard = require('./cascadeGuard.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const { formatCrossTeamText, CROSS_TEAM_SOURCE } = require('./crossTeamProtocol.js');
|
||||
const {
|
||||
formatCrossTeamText,
|
||||
CROSS_TEAM_SOURCE,
|
||||
CROSS_TEAM_SENT_SOURCE,
|
||||
} = require('./crossTeamProtocol.js');
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
|
@ -180,6 +185,7 @@ function sendCrossTeamMessage(context, flags) {
|
|||
replyToConversationId: replyToConversationId || undefined,
|
||||
});
|
||||
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
|
||||
|
||||
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
|
||||
|
|
@ -206,7 +212,7 @@ function sendCrossTeamMessage(context, flags) {
|
|||
from,
|
||||
to: leadName,
|
||||
text: formattedText,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp,
|
||||
read: false,
|
||||
summary: summary || `Cross-team message from ${fromTeam}`,
|
||||
messageId,
|
||||
|
|
@ -224,6 +230,18 @@ function sendCrossTeamMessage(context, flags) {
|
|||
throw new Error('Cross-team inbox write verification failed');
|
||||
}
|
||||
|
||||
messageStore.appendSentMessage(context.paths, {
|
||||
from: fromMember,
|
||||
to: `${toTeam}.${leadName}`,
|
||||
text,
|
||||
timestamp,
|
||||
messageId,
|
||||
summary: summary || `Cross-team message to ${toTeam}`,
|
||||
source: CROSS_TEAM_SENT_SOURCE,
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
});
|
||||
|
||||
outList.push({
|
||||
messageId,
|
||||
fromTeam,
|
||||
|
|
@ -234,7 +252,7 @@ function sendCrossTeamMessage(context, flags) {
|
|||
text,
|
||||
summary,
|
||||
chainDepth,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp,
|
||||
});
|
||||
writeJson(outboxPath, outList);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,6 +87,15 @@ describe('crossTeam module', () => {
|
|||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0].toTeam).toBe('team-b');
|
||||
expect(outbox[0].conversationId).toBeTruthy();
|
||||
|
||||
const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json');
|
||||
const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8'));
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
expect(sentMessages[0].from).toBe('team-lead');
|
||||
expect(sentMessages[0].to).toBe('team-b.team-lead');
|
||||
expect(sentMessages[0].text).toBe('Hello');
|
||||
expect(sentMessages[0].source).toBe('cross_team_sent');
|
||||
expect(sentMessages[0].messageId).toBe(outbox[0].messageId);
|
||||
});
|
||||
|
||||
it('preserves reply conversation metadata for explicit replies', () => {
|
||||
|
|
@ -152,6 +161,10 @@ describe('crossTeam module', () => {
|
|||
|
||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox).toHaveLength(1);
|
||||
|
||||
const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json');
|
||||
const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8'));
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows resending after dedupe window expires', () => {
|
||||
|
|
|
|||
210
docs/research/split-screen-multi-view.md
Normal file
210
docs/research/split-screen-multi-view.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# Split Screen Multi-View Research
|
||||
|
||||
> Исследование: поддержка одновременного просмотра нескольких сессий/команд в split pane.
|
||||
> Дата: 2026-03-10
|
||||
|
||||
## Текущее состояние архитектуры
|
||||
|
||||
### Split Pane System (уже реализовано)
|
||||
- До **4 панелей** одновременно (`MAX_PANES = 4` в `src/renderer/types/panes.ts`)
|
||||
- Drag-and-drop между панелями (dnd-kit, `TabbedLayout.tsx`)
|
||||
- Resize handles между панелями (`PaneResizeHandle.tsx`)
|
||||
- CSS `display: none` toggle — все вкладки mounted, только active видна (`PaneContent.tsx`)
|
||||
- `TabUIContext` предоставляет `tabId` потомкам
|
||||
|
||||
### Pane Layout Structure
|
||||
```typescript
|
||||
// src/renderer/types/panes.ts
|
||||
interface Pane {
|
||||
id: string;
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
selectedTabIds: string[];
|
||||
widthFraction: number; // 0-1, сумма всех = 1.0
|
||||
}
|
||||
|
||||
interface PaneLayout {
|
||||
panes: Pane[];
|
||||
focusedPaneId: string; // какая панель в фокусе
|
||||
}
|
||||
```
|
||||
|
||||
### Backward Compatibility Facade
|
||||
Root-level `openTabs`, `activeTabId`, `selectedTabIds` синхронизируются из **focused pane only** через `syncFromLayout()` в `tabSlice.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Изоляция состояния: что per-tab vs глобальное
|
||||
|
||||
### ✅ Per-Tab (уже изолировано)
|
||||
|
||||
| Состояние | Хранение | Слайс |
|
||||
|-----------|----------|-------|
|
||||
| UI expansion state | `tabUIStates[tabId]` | `tabUISlice` |
|
||||
| Scroll position | `tabUIStates[tabId].savedScrollTop` | `tabUISlice` |
|
||||
| Context panel visibility | `tabUIStates[tabId].showContextPanel` | `tabUISlice` |
|
||||
| Context phase selection | `tabUIStates[tabId].selectedContextPhase` | `tabUISlice` |
|
||||
| Session data cache | `tabSessionData[tabId]` | `sessionDetailSlice` |
|
||||
| Conversation cache | `tabSessionData[tabId].conversation` | `sessionDetailSlice` |
|
||||
|
||||
**Паттерн чтения:**
|
||||
```typescript
|
||||
const stats = useStore((s) => {
|
||||
const td = tabId ? s.tabSessionData[tabId] : null;
|
||||
return td?.sessionClaudeMdStats ?? s.sessionClaudeMdStats;
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ Глобальное (проблемы для multi-view)
|
||||
|
||||
| Состояние | Слайс | Проблема |
|
||||
|-----------|-------|----------|
|
||||
| `selectedTeamName` | `teamSlice` | Одна команда на всё приложение |
|
||||
| `selectedTeamData` | `teamSlice` | Полные данные только одной команды |
|
||||
| `searchQuery` | `conversationSlice` | Поиск общий для всех вкладок |
|
||||
| `searchVisible` | `conversationSlice` | Показ поиска общий |
|
||||
| `searchMatches` | `conversationSlice` | Результаты поиска общие |
|
||||
| `currentSearchIndex` | `conversationSlice` | Навигация по результатам общая |
|
||||
| `expandedAIGroupIds` | `conversationSlice` | Legacy дубль `tabUISlice` |
|
||||
| `expandedDisplayItemIds` | `conversationSlice` | Legacy дубль `tabUISlice` |
|
||||
| `expandedStepIds` | `conversationSlice` | Глобальное, логично per-tab |
|
||||
| `activeDetailItem` | `conversationSlice` | Глобальное, логично per-tab |
|
||||
|
||||
### ⚠️ Синхронизируемое (работает через swap)
|
||||
|
||||
| Состояние | Механизм |
|
||||
|-----------|----------|
|
||||
| `selectedProjectId` | Swap при фокусе pane |
|
||||
| `selectedSessionId` | Swap при фокусе pane |
|
||||
| `sessionDetail` (global) | Swap из `tabSessionData[tabId]` |
|
||||
| `conversation` (global) | Swap из `tabSessionData[tabId]` |
|
||||
|
||||
---
|
||||
|
||||
## Варианты реализации
|
||||
|
||||
### Вариант A: Полная поддержка split-screen для сессий
|
||||
**Надёжность: 8/10 | Уверенность: 9/10**
|
||||
|
||||
Основа уже заложена через `tabSessionData`. Нужно:
|
||||
|
||||
1. **Search isolation** (~5 файлов):
|
||||
- Перенести `searchQuery`, `searchVisible`, `searchMatches`, `currentSearchIndex` в `tabUISlice`
|
||||
- Обновить `SearchBar`, `useSearchContextNavigation`, `searchHighlightUtils`
|
||||
- Компоненты читают search state через `tabUIStates[tabId]`
|
||||
|
||||
2. **Legacy cleanup** (~3 файла):
|
||||
- Удалить `expandedAIGroupIds` и `expandedDisplayItemIds` из `conversationSlice`
|
||||
- Убедиться все компоненты используют `tabUISlice` версии
|
||||
- Удалить `expandedStepIds` из global scope
|
||||
|
||||
3. **Верификация** (~3 файла):
|
||||
- Проверить все компоненты в chat/ читают через `tabSessionData[tabId]` паттерн
|
||||
- Проверить что `activeDetailItem` изолирован
|
||||
|
||||
**Объём: ~8-12 файлов, средняя сложность.**
|
||||
|
||||
### Вариант B: Полная поддержка split-screen для команд
|
||||
**Надёжность: 7/10 | Уверенность: 7/10**
|
||||
|
||||
Нужна новая инфраструктура:
|
||||
|
||||
1. **Per-tab team data cache** (~5 файлов):
|
||||
```typescript
|
||||
// В teamSlice или sessionDetailSlice
|
||||
tabTeamData: Record<string, {
|
||||
teamName: string;
|
||||
teamData: TeamData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}>
|
||||
```
|
||||
|
||||
2. **selectTeam() с tabId** (~3 файла):
|
||||
- `selectTeam(teamName, tabId?)` — кэширует в `tabTeamData[tabId]`
|
||||
- При переключении tab: swap из кэша или fetch
|
||||
- При закрытии tab: cleanup кэша
|
||||
|
||||
3. **Team компоненты** (~8 файлов):
|
||||
- `TeamDetailView`, `TeamChatView`, `TeamKanbanView` и др.
|
||||
- Читать через `tabTeamData[tabId]` паттерн
|
||||
- File watcher: обновлять нужные tab кэши
|
||||
|
||||
4. **Sidebar sync** (~2 файла):
|
||||
- При фокусе pane с team tab: sync sidebar к этой команде
|
||||
|
||||
**Объём: ~15-20 файлов, высокая сложность.**
|
||||
|
||||
### Вариант C: A + B (полный split-screen)
|
||||
**Надёжность: 6/10 | Уверенность: 7/10**
|
||||
|
||||
**Объём: ~20-25 файлов.**
|
||||
|
||||
---
|
||||
|
||||
## Риски
|
||||
|
||||
### Высокие
|
||||
1. **Race conditions при file watcher events** — обновление прилетает, нужно обновить правильный tab cache. Для сессий решено через `tabFetchGeneration` Map, для команд нужен аналог.
|
||||
2. **Search isolation** — search завязан на глобальные `searchMatches` и навигацию по ним, самый трудоёмкий рефактор.
|
||||
|
||||
### Средние
|
||||
3. **Memory pressure** — каждый tab хранит полный кэш. Для сессий работает (cleanup при закрытии). Для команд нужен аналог.
|
||||
4. **Sidebar sync** — сайдбар показывает контекст focused pane. При переключении нужен корректный swap project/worktree/team.
|
||||
5. **Stale data** — два tab с одной сессией/командой: file watcher обновляет оба или только active?
|
||||
|
||||
### Низкие
|
||||
6. **DnD between panes** — перетаскивание team tab между panes должно триггерить cache transfer.
|
||||
7. **Tab duplication** — `openTab()` проверяет дупликаты across ALL panes. Нужно ли разрешить одну и ту же команду в двух panes?
|
||||
|
||||
---
|
||||
|
||||
## Ключевые файлы
|
||||
|
||||
### Store Slices
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| `src/renderer/store/slices/tabSlice.ts` | Tab lifecycle, session switching, backward compat |
|
||||
| `src/renderer/store/slices/paneSlice.ts` | Multi-pane split/resize/focus |
|
||||
| `src/renderer/store/slices/tabUISlice.ts` | Per-tab UI state (expansion, scroll) |
|
||||
| `src/renderer/store/slices/sessionDetailSlice.ts` | Session data + per-tab caching |
|
||||
| `src/renderer/store/slices/conversationSlice.ts` | Search, legacy expansion (нужен рефактор) |
|
||||
| `src/renderer/store/slices/teamSlice.ts` | Team selection (глобальное, нужен рефактор) |
|
||||
|
||||
### Layout Components
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| `src/renderer/components/layout/TabbedLayout.tsx` | Main layout + DnD context |
|
||||
| `src/renderer/components/layout/TabBarRow.tsx` | Full-width tab bar (pane-proportional) |
|
||||
| `src/renderer/components/layout/TabBar.tsx` | Single pane tab bar |
|
||||
| `src/renderer/components/layout/PaneContainer.tsx` | Split layout renderer |
|
||||
| `src/renderer/components/layout/PaneView.tsx` | Single pane wrapper |
|
||||
| `src/renderer/components/layout/PaneContent.tsx` | Tab content renderer (display-toggle) |
|
||||
| `src/renderer/components/layout/SessionTabContent.tsx` | Session tab content |
|
||||
|
||||
### Contexts
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| `src/renderer/contexts/TabUIContext.tsx` | Per-tab ID provider |
|
||||
| `src/renderer/contexts/useTabUIContext.ts` | Context hook |
|
||||
|
||||
---
|
||||
|
||||
## Рекомендация
|
||||
|
||||
**Начать с Варианта A** (сессии в split-screen):
|
||||
- 80% инфраструктуры уже есть
|
||||
- Нужно дочистить search isolation и legacy duplicates
|
||||
- Низкий риск регрессий
|
||||
|
||||
**Затем Вариант B** (команды):
|
||||
- Когда паттерн per-tab caching отработан на сессиях
|
||||
- Применить тот же подход к team data
|
||||
|
||||
---
|
||||
|
||||
## Обнаруженные баги (побочный результат ресёрча)
|
||||
|
||||
1. **Search state не изолирован** — поиск в одной вкладке влияет на другие
|
||||
2. **Legacy дублирование** — `expandedAIGroupIds` существует и в `conversationSlice` и в `tabUISlice`
|
||||
3. **Team tabs в split pane** — обе панели показывают одну команду (последнюю выбранную)
|
||||
|
|
@ -10,7 +10,12 @@ import https from 'node:https';
|
|||
import http from 'node:http';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { McpCatalogItem, McpEnvVarDef, McpInstallSpec } from '@shared/types/extensions';
|
||||
import type {
|
||||
McpAuthHeaderDef,
|
||||
McpCatalogItem,
|
||||
McpEnvVarDef,
|
||||
McpInstallSpec,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:OfficialMcpRegistry');
|
||||
|
||||
|
|
@ -265,6 +270,7 @@ export class OfficialMcpRegistryService {
|
|||
const meta = entry._meta?.['io.modelcontextprotocol.registry/official'];
|
||||
const installSpec = this.deriveInstallSpec(server);
|
||||
const envVars = this.collectEnvVars(server);
|
||||
const authHeaders = this.collectAuthHeaders(server);
|
||||
const requiresAuth = this.detectAuthRequired(server);
|
||||
|
||||
return {
|
||||
|
|
@ -285,6 +291,7 @@ export class OfficialMcpRegistryService {
|
|||
status: meta?.status,
|
||||
publishedAt: meta?.publishedAt,
|
||||
updatedAt: meta?.updatedAt,
|
||||
authHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -330,6 +337,30 @@ export class OfficialMcpRegistryService {
|
|||
return envVars;
|
||||
}
|
||||
|
||||
private collectAuthHeaders(server: RegistryServerEntry['server']): McpAuthHeaderDef[] {
|
||||
const headers: McpAuthHeaderDef[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
for (const remote of server.remotes ?? []) {
|
||||
for (const header of remote.headers ?? []) {
|
||||
const key = header.name.trim();
|
||||
if (!key || seenKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
headers.push({
|
||||
key,
|
||||
description: header.description,
|
||||
isRequired: header.isRequired,
|
||||
isSecret: header.isSecret,
|
||||
valueTemplate: header.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private detectAuthRequired(server: RegistryServerEntry['server']): boolean {
|
||||
for (const remote of server.remotes ?? []) {
|
||||
for (const header of remote.headers ?? []) {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ interface ProvisioningRun {
|
|||
leadMsgSeq: number;
|
||||
/** Accumulated tool_use details between text messages. */
|
||||
pendingToolCalls: ToolCallMeta[];
|
||||
/** True when a direct MCP cross_team_send happened and sentMessages history should refresh. */
|
||||
pendingDirectCrossTeamSendRefresh: boolean;
|
||||
/** Throttle timestamp for emitting inbox refresh events for lead text. */
|
||||
lastLeadTextEmitMs: number;
|
||||
/**
|
||||
|
|
@ -1090,6 +1092,7 @@ function isTransientProbeWarning(warning: string): boolean {
|
|||
|
||||
export class TeamProvisioningService {
|
||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
private readonly runs = new Map<string, ProvisioningRun>();
|
||||
private readonly activeByTeam = new Map<string, string>();
|
||||
|
|
@ -1099,6 +1102,7 @@ export class TeamProvisioningService {
|
|||
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 recentCrossTeamLeadDeliveryMessageIds = 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;
|
||||
|
|
@ -1367,12 +1371,90 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private getPendingCrossTeamReplyExpectationKeys(teamName: string): Set<string> {
|
||||
const teamMap = this.pendingCrossTeamFirstReplies.get(teamName.trim());
|
||||
if (!teamMap) return new Set<string>();
|
||||
const cutoff = Date.now() - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_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 getRunLeadName(run: ProvisioningRun): string {
|
||||
return (
|
||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'
|
||||
);
|
||||
}
|
||||
|
||||
private rememberRecentCrossTeamLeadDeliveryMessageIds(
|
||||
teamName: string,
|
||||
messageIds: string[]
|
||||
): void {
|
||||
const normalizedIds = messageIds.map((id) => id.trim()).filter((id) => id.length > 0);
|
||||
if (normalizedIds.length === 0) return;
|
||||
const teamKey = teamName.trim();
|
||||
const current =
|
||||
this.recentCrossTeamLeadDeliveryMessageIds.get(teamKey) ?? new Map<string, number>();
|
||||
const now = Date.now();
|
||||
const cutoff = now - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS;
|
||||
for (const [key, createdAt] of current.entries()) {
|
||||
if (createdAt < cutoff) current.delete(key);
|
||||
}
|
||||
for (const messageId of normalizedIds) {
|
||||
current.set(messageId, now);
|
||||
}
|
||||
if (current.size > 0) {
|
||||
this.recentCrossTeamLeadDeliveryMessageIds.set(teamKey, current);
|
||||
}
|
||||
}
|
||||
|
||||
private wasRecentlyDeliveredToLead(teamName: string, messageId: string): boolean {
|
||||
const normalizedMessageId = messageId.trim();
|
||||
if (!normalizedMessageId) return false;
|
||||
const teamKey = teamName.trim();
|
||||
const current = this.recentCrossTeamLeadDeliveryMessageIds.get(teamKey);
|
||||
if (!current) return false;
|
||||
const cutoff = Date.now() - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS;
|
||||
for (const [key, createdAt] of current.entries()) {
|
||||
if (createdAt < cutoff) current.delete(key);
|
||||
}
|
||||
if (current.size === 0) {
|
||||
this.recentCrossTeamLeadDeliveryMessageIds.delete(teamKey);
|
||||
return false;
|
||||
}
|
||||
return current.has(normalizedMessageId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private extractStreamUserText(msg: Record<string, unknown>): string | null {
|
||||
const topLevelContent = msg.content;
|
||||
if (typeof topLevelContent === 'string') {
|
||||
|
|
@ -1415,29 +1497,48 @@ export class TeamProvisioningService {
|
|||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
private async markDeliveredCrossTeamLeadMessagesRead(
|
||||
private async matchCrossTeamLeadInboxMessages(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
deliveredBlocks: Array<{
|
||||
teammateId: string;
|
||||
content: string;
|
||||
toTeam: string;
|
||||
conversationId: string;
|
||||
}>
|
||||
): Promise<void> {
|
||||
if (deliveredBlocks.length === 0) return;
|
||||
): Promise<
|
||||
Array<{
|
||||
teammateId: string;
|
||||
content: string;
|
||||
toTeam: string;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
wasRead: boolean;
|
||||
}>
|
||||
> {
|
||||
if (deliveredBlocks.length === 0) return [];
|
||||
|
||||
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
||||
try {
|
||||
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
|
||||
} catch {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
const toMark: (InboxMessage & { messageId: string })[] = [];
|
||||
const usedMessageIds = new Set<string>();
|
||||
const matches: Array<{
|
||||
teammateId: string;
|
||||
content: string;
|
||||
toTeam: string;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
wasRead: boolean;
|
||||
}> = [];
|
||||
for (const block of deliveredBlocks) {
|
||||
const matchesBlock = (message: InboxMessage, requireExactText: boolean): boolean => {
|
||||
if (message.read || message.source !== CROSS_TEAM_SOURCE) return false;
|
||||
if (message.source !== CROSS_TEAM_SOURCE) return false;
|
||||
if (!this.hasStableMessageId(message)) return false;
|
||||
if (usedMessageIds.has(message.messageId)) return false;
|
||||
if (message.from.trim() !== block.teammateId.trim()) return false;
|
||||
const messageConversationId =
|
||||
message.replyToConversationId?.trim() ??
|
||||
|
|
@ -1450,17 +1551,18 @@ export class TeamProvisioningService {
|
|||
leadInboxMessages.find((message) => matchesBlock(message, true)) ??
|
||||
leadInboxMessages.find((message) => matchesBlock(message, false));
|
||||
if (!matched || !this.hasStableMessageId(matched)) continue;
|
||||
matched.read = true;
|
||||
toMark.push(matched);
|
||||
usedMessageIds.add(matched.messageId);
|
||||
matches.push({
|
||||
teammateId: block.teammateId,
|
||||
content: block.content,
|
||||
toTeam: block.toTeam,
|
||||
conversationId: block.conversationId,
|
||||
messageId: matched.messageId,
|
||||
wasRead: matched.read === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (toMark.length === 0) return;
|
||||
|
||||
try {
|
||||
await this.markInboxMessagesRead(teamName, leadName, toMark);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private handleNativeTeammateUserMessage(
|
||||
|
|
@ -1490,13 +1592,33 @@ export class TeamProvisioningService {
|
|||
});
|
||||
if (crossTeamBlocks.length === 0) return;
|
||||
|
||||
run.activeCrossTeamReplyHints = crossTeamBlocks.map((block) => ({
|
||||
toTeam: block.toTeam,
|
||||
conversationId: block.conversationId,
|
||||
}));
|
||||
|
||||
const leadName = this.getRunLeadName(run);
|
||||
void this.markDeliveredCrossTeamLeadMessagesRead(run.teamName, leadName, crossTeamBlocks);
|
||||
void (async () => {
|
||||
const matches = await this.matchCrossTeamLeadInboxMessages(
|
||||
run.teamName,
|
||||
leadName,
|
||||
crossTeamBlocks
|
||||
);
|
||||
const unreadMatches = matches.filter((match) => !match.wasRead);
|
||||
if (unreadMatches.length > 0) {
|
||||
try {
|
||||
await this.markInboxMessagesRead(run.teamName, leadName, unreadMatches);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
const freshMatches = matches.filter(
|
||||
(match) => !this.wasRecentlyDeliveredToLead(run.teamName, match.messageId)
|
||||
);
|
||||
this.rememberRecentCrossTeamLeadDeliveryMessageIds(
|
||||
run.teamName,
|
||||
freshMatches.map((match) => match.messageId)
|
||||
);
|
||||
run.activeCrossTeamReplyHints = freshMatches.map((match) => ({
|
||||
toTeam: match.toTeam,
|
||||
conversationId: match.conversationId,
|
||||
}));
|
||||
})();
|
||||
}
|
||||
|
||||
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||
|
|
@ -2216,6 +2338,7 @@ export class TeamProvisioningService {
|
|||
activeCrossTeamReplyHints: [],
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCalls: [],
|
||||
pendingDirectCrossTeamSendRefresh: false,
|
||||
lastLeadTextEmitMs: 0,
|
||||
silentUserDmForward: null,
|
||||
silentUserDmForwardClearHandle: null,
|
||||
|
|
@ -2540,6 +2663,7 @@ export class TeamProvisioningService {
|
|||
activeCrossTeamReplyHints: [],
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCalls: [],
|
||||
pendingDirectCrossTeamSendRefresh: false,
|
||||
lastLeadTextEmitMs: 0,
|
||||
silentUserDmForward: null,
|
||||
silentUserDmForwardClearHandle: null,
|
||||
|
|
@ -3053,14 +3177,80 @@ export class TeamProvisioningService {
|
|||
|
||||
if (unread.length === 0) return 0;
|
||||
|
||||
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 = 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 wasRecentlyDeliveredCrossTeam = (message: InboxMessage): boolean => {
|
||||
if (message.source !== CROSS_TEAM_SOURCE) return false;
|
||||
if (!this.hasStableMessageId(message)) return false;
|
||||
return this.wasRecentlyDeliveredToLead(teamName, message.messageId);
|
||||
};
|
||||
const isCrossTeamReplyToOwnOutbound = (message: InboxMessage): boolean => {
|
||||
if (message.source !== CROSS_TEAM_SOURCE) return false;
|
||||
const conversationId =
|
||||
message.replyToConversationId?.trim() ??
|
||||
message.conversationId?.trim() ??
|
||||
parseCrossTeamPrefix(message.text)?.conversationId;
|
||||
if (!conversationId) return false;
|
||||
const sourceTeam = this.getCrossTeamSourceTeam(message.from);
|
||||
if (!sourceTeam) return false;
|
||||
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.
|
||||
// Also ignore local sender-copy rows for cross-team traffic: those exist only so the UI
|
||||
// can show outbound activity and must not be re-injected into the live lead as new work.
|
||||
// Incoming cross-team deliveries are handled through Claude's native <teammate-message>
|
||||
// path and are marked read when that raw user turn is observed, so we intentionally do not
|
||||
// custom-relay them here.
|
||||
// If the same cross-team delivery already arrived via a raw <teammate-message> turn,
|
||||
// suppress the duplicate relay here and simply mark the inbox row as read.
|
||||
const ignoredUnread = unread.filter(
|
||||
(m) => isInboxNoiseMessage(m.text) || m.source === CROSS_TEAM_SENT_SOURCE
|
||||
(m) =>
|
||||
isInboxNoiseMessage(m.text) ||
|
||||
m.source === CROSS_TEAM_SENT_SOURCE ||
|
||||
isCrossTeamReplyToOwnOutbound(m) ||
|
||||
wasRecentlyDeliveredCrossTeam(m)
|
||||
);
|
||||
if (ignoredUnread.length > 0) {
|
||||
try {
|
||||
|
|
@ -3068,13 +3258,20 @@ 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(
|
||||
(m) =>
|
||||
!isInboxNoiseMessage(m.text) &&
|
||||
m.source !== CROSS_TEAM_SENT_SOURCE &&
|
||||
m.source !== CROSS_TEAM_SOURCE
|
||||
!isCrossTeamReplyToOwnOutbound(m) &&
|
||||
!wasRecentlyDeliveredCrossTeam(m)
|
||||
);
|
||||
if (actionableUnread.length === 0) return 0;
|
||||
|
||||
|
|
@ -3187,6 +3384,12 @@ export class TeamProvisioningService {
|
|||
relayedIds.add(m.messageId);
|
||||
}
|
||||
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
|
||||
this.rememberRecentCrossTeamLeadDeliveryMessageIds(
|
||||
teamName,
|
||||
batch
|
||||
.filter((message) => message.source === CROSS_TEAM_SOURCE)
|
||||
.map((message) => message.messageId)
|
||||
);
|
||||
|
||||
try {
|
||||
await this.markInboxMessagesRead(teamName, leadName, batch);
|
||||
|
|
@ -3435,11 +3638,22 @@ export class TeamProvisioningService {
|
|||
if (part.type !== 'tool_use' || typeof part.name !== 'string') continue;
|
||||
const isNativeSendMessage = part.name === 'SendMessage';
|
||||
const isTeamMessageSendTool = part.name === 'mcp__agent-teams__message_send';
|
||||
if (!isNativeSendMessage && !isTeamMessageSendTool) continue;
|
||||
const isDirectCrossTeamSendTool =
|
||||
part.name === 'mcp__agent-teams__cross_team_send' || part.name === 'cross_team_send';
|
||||
if (!isNativeSendMessage && !isTeamMessageSendTool && !isDirectCrossTeamSendTool) continue;
|
||||
const input = part.input;
|
||||
if (!input || typeof input !== 'object') continue;
|
||||
const inp = input as Record<string, unknown>;
|
||||
|
||||
if (isDirectCrossTeamSendTool) {
|
||||
const toTeam = typeof inp.toTeam === 'string' ? inp.toTeam.trim() : '';
|
||||
const text = typeof inp.text === 'string' ? stripAgentBlocks(inp.text).trim() : '';
|
||||
if (toTeam && text) {
|
||||
run.pendingDirectCrossTeamSendRefresh = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = isNativeSendMessage
|
||||
? typeof inp.recipient === 'string'
|
||||
? inp.recipient
|
||||
|
|
@ -3998,6 +4212,14 @@ export class TeamProvisioningService {
|
|||
|
||||
this.setLeadActivity(run, 'idle');
|
||||
}
|
||||
if (run.pendingDirectCrossTeamSendRefresh) {
|
||||
run.pendingDirectCrossTeamSendRefresh = false;
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName: run.teamName,
|
||||
detail: 'sentMessages.json',
|
||||
});
|
||||
}
|
||||
if (run.leadRelayCapture) {
|
||||
const capture = run.leadRelayCapture;
|
||||
const combined = capture.textParts.join('\n').trim();
|
||||
|
|
@ -4033,6 +4255,7 @@ export class TeamProvisioningService {
|
|||
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||
}
|
||||
// Clear silent relay flag after any errored turn.
|
||||
run.pendingDirectCrossTeamSendRefresh = false;
|
||||
run.activeCrossTeamReplyHints = [];
|
||||
run.silentUserDmForward = null;
|
||||
if (run.silentUserDmForwardClearHandle) {
|
||||
|
|
@ -4757,6 +4980,7 @@ export class TeamProvisioningService {
|
|||
*/
|
||||
private cleanupRun(run: ProvisioningRun): void {
|
||||
this.setLeadActivity(run, 'offline');
|
||||
run.pendingDirectCrossTeamSendRefresh = false;
|
||||
if (run.timeoutHandle) {
|
||||
clearTimeout(run.timeoutHandle);
|
||||
run.timeoutHandle = null;
|
||||
|
|
@ -4776,6 +5000,7 @@ export class TeamProvisioningService {
|
|||
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
||||
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
||||
run.activeCrossTeamReplyHints = [];
|
||||
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||
if (key.startsWith(`${run.teamName}:`)) {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,10 @@ interface CustomMcpServerDialogProps {
|
|||
|
||||
type TransportMode = 'stdio' | 'http';
|
||||
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
||||
type Scope = 'local' | 'user' | 'project';
|
||||
type Scope = 'local' | 'user';
|
||||
|
||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||
{ value: 'user', label: 'User (global)' },
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'local', label: 'Local' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { api } from '@renderer/api';
|
||||
|
|
@ -45,6 +46,11 @@ export const McpServerCard = ({
|
|||
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||
);
|
||||
const canAutoInstall = !!server.installSpec;
|
||||
const requiresConfiguration =
|
||||
server.installSpec?.type === 'http' ||
|
||||
server.envVars.length > 0 ||
|
||||
server.requiresAuth ||
|
||||
(server.authHeaders?.length ?? 0) > 0;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const hasIcon = !!server.iconUrl && !imgError;
|
||||
|
||||
|
|
@ -197,7 +203,7 @@ export const McpServerCard = ({
|
|||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{canAutoInstall && (
|
||||
{canAutoInstall && !requiresConfiguration && (
|
||||
<div className="shrink-0">
|
||||
<InstallButton
|
||||
state={installProgress}
|
||||
|
|
@ -217,6 +223,20 @@ export const McpServerCard = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{canAutoInstall && requiresConfiguration && (
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(server.id);
|
||||
}}
|
||||
>
|
||||
{isInstalled ? 'Manage' : 'Configure'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,11 +40,10 @@ interface McpServerDetailDialogProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Scope = 'local' | 'user' | 'project';
|
||||
type Scope = 'local' | 'user';
|
||||
|
||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||
{ value: 'user', label: 'User (global)' },
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'local', label: 'Local' },
|
||||
];
|
||||
|
||||
|
|
@ -71,16 +70,29 @@ export const McpServerDetailDialog = ({
|
|||
const [imgError, setImgError] = useState(false);
|
||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize form when server changes
|
||||
const [lastServerId, setLastServerId] = useState<string | null>(null);
|
||||
if (server && server.id !== lastServerId) {
|
||||
setLastServerId(server.id);
|
||||
// Initialize form when dialog opens or server changes
|
||||
useEffect(() => {
|
||||
if (!server || !open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerName(sanitizeMcpServerName(server.name));
|
||||
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
||||
setHeaders([]);
|
||||
setHeaders(
|
||||
(server.authHeaders ?? []).map((header) => ({
|
||||
key: header.key,
|
||||
value: '',
|
||||
secret: header.isSecret,
|
||||
description: header.description,
|
||||
isRequired: header.isRequired,
|
||||
valueTemplate: header.valueTemplate,
|
||||
locked: true,
|
||||
}))
|
||||
);
|
||||
setScope('user');
|
||||
setImgError(false);
|
||||
setAutoFilledFields(new Set());
|
||||
}
|
||||
}, [server?.id, open]);
|
||||
|
||||
// Auto-fill env values from saved API keys
|
||||
useEffect(() => {
|
||||
|
|
@ -142,6 +154,18 @@ export const McpServerDetailDialog = ({
|
|||
const canAutoInstall = !!server.installSpec;
|
||||
const isHttp = server.installSpec?.type === 'http';
|
||||
const hasIcon = !!server.iconUrl && !imgError;
|
||||
const npmPackageUrl =
|
||||
server.installSpec?.type === 'stdio'
|
||||
? `https://www.npmjs.com/package/${server.installSpec.npmPackage}`
|
||||
: null;
|
||||
const hasSuggestedHeaders = headers.some((header) => header.locked);
|
||||
const missingRequiredEnvVars = server.envVars.some(
|
||||
(env) => env.isRequired && !envValues[env.name]?.trim()
|
||||
);
|
||||
const missingRequiredHeaders = headers.some(
|
||||
(header) => header.isRequired && !header.value.trim()
|
||||
);
|
||||
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
|
||||
|
||||
const handleInstall = () => {
|
||||
installMcpServer({
|
||||
|
|
@ -236,13 +260,21 @@ export const McpServerDetailDialog = ({
|
|||
)}
|
||||
<div>
|
||||
<span className="text-text-muted">Install Type</span>
|
||||
<p className="text-text">
|
||||
{server.installSpec
|
||||
? server.installSpec.type === 'stdio'
|
||||
? `npm: ${server.installSpec.npmPackage}`
|
||||
: `HTTP: ${server.installSpec.transportType}`
|
||||
: 'Manual setup required'}
|
||||
</p>
|
||||
{server.installSpec?.type === 'stdio' ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-sm text-blue-400"
|
||||
onClick={() => void api.openExternal(npmPackageUrl!)}
|
||||
>
|
||||
npm: {server.installSpec.npmPackage}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-text">
|
||||
{server.installSpec
|
||||
? `HTTP: ${server.installSpec.transportType}`
|
||||
: 'Manual setup required'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{server.author && (
|
||||
<div>
|
||||
|
|
@ -277,6 +309,12 @@ export const McpServerDetailDialog = ({
|
|||
This server requires authentication
|
||||
</div>
|
||||
)}
|
||||
{isHttp && !server.requiresAuth && (server.authHeaders?.length ?? 0) === 0 && (
|
||||
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-sm text-blue-400">
|
||||
Remote MCP servers may still require custom headers or API keys even when the registry
|
||||
does not describe them. If connection fails after install, check the provider docs.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install form */}
|
||||
{canAutoInstall && (
|
||||
|
|
@ -356,34 +394,54 @@ export const McpServerDetailDialog = ({
|
|||
className="h-6 px-1.5 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Add
|
||||
{hasSuggestedHeaders ? 'Add custom' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
{headers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{headers.map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
||||
className="h-7 w-32 text-xs"
|
||||
placeholder="Header-Name"
|
||||
/>
|
||||
<Input
|
||||
type={header.secret ? 'password' : 'text'}
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
||||
className="h-7 flex-1 text-xs"
|
||||
placeholder="value"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => removeHeader(index)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{header.locked ? (
|
||||
<code className="w-32 shrink-0 truncate text-xs text-blue-400">
|
||||
{header.key}
|
||||
</code>
|
||||
) : (
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
||||
className="h-7 w-32 text-xs"
|
||||
placeholder="Header-Name"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
type={header.secret ? 'password' : 'text'}
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
||||
className="h-7 flex-1 text-xs"
|
||||
placeholder={header.valueTemplate ?? header.description ?? 'value'}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => removeHeader(index)}
|
||||
disabled={header.locked && header.isRequired}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{(header.description || header.valueTemplate || header.isRequired) && (
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{[
|
||||
header.isRequired ? 'Required' : null,
|
||||
header.description,
|
||||
header.valueTemplate,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -398,7 +456,7 @@ export const McpServerDetailDialog = ({
|
|||
isInstalled={isInstalled}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
disabled={!serverName.trim()}
|
||||
disabled={installDisabled}
|
||||
size="default"
|
||||
errorMessage={installError}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions.
|
||||
*
|
||||
* Groups: Search, Export (session-only), Analyze (session-only), Settings.
|
||||
* Groups: Search, Export (session-only), Analyze (session-only).
|
||||
* Closes on outside click or Escape.
|
||||
*/
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { triggerDownload } from '@renderer/utils/sessionExporter';
|
||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
||||
import { Activity, Braces, FileText, MoreHorizontal, Search, Settings, Type } from 'lucide-react';
|
||||
import { Activity, Braces, FileText, MoreHorizontal, Search, Type } from 'lucide-react';
|
||||
|
||||
import type { SessionDetail } from '@renderer/types/data';
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
|
|
@ -41,7 +41,6 @@ export const MoreMenu = ({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openCommandPalette = useStore((s) => s.openCommandPalette);
|
||||
const openSettingsTab = useStore((s) => s.openSettingsTab);
|
||||
const openSessionReport = useStore((s) => s.openSessionReport);
|
||||
|
||||
// Close on outside click
|
||||
|
|
@ -133,19 +132,6 @@ export const MoreMenu = ({
|
|||
]
|
||||
: [];
|
||||
|
||||
const bottomItems: MenuItem[] = [
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
shortcut: formatShortcut(','),
|
||||
onClick: () => {
|
||||
openSettingsTab();
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const renderItem = (item: MenuItem): React.JSX.Element => (
|
||||
<button
|
||||
key={item.id}
|
||||
|
|
@ -192,7 +178,7 @@ export const MoreMenu = ({
|
|||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 mt-1 w-52 overflow-hidden rounded-md border shadow-lg"
|
||||
className="absolute right-0 top-full z-50 mt-1 w-64 overflow-hidden rounded-md border py-1 shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border)',
|
||||
|
|
@ -206,9 +192,6 @@ export const MoreMenu = ({
|
|||
{sessionItems.map(renderItem)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{separator}
|
||||
{bottomItems.map(renderItem)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ export const TabBarActions = (): React.JSX.Element => {
|
|||
<Settings className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* More menu (Search, Export, Analyze, Settings) */}
|
||||
{/* More menu (Search, Export, Analyze) */}
|
||||
<MoreMenu
|
||||
activeTab={activeTab}
|
||||
activeTabSessionDetail={activeTabSessionDetail}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function linkifyTeamMentionsInMarkdown(
|
|||
|
||||
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
||||
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`;
|
||||
return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export { inferCapabilities } from './plugin';
|
|||
|
||||
export type {
|
||||
InstalledMcpEntry,
|
||||
McpAuthHeaderDef,
|
||||
McpCatalogItem,
|
||||
McpCustomInstallRequest,
|
||||
McpEnvVarDef,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface McpCatalogItem {
|
|||
updatedAt?: string;
|
||||
author?: string;
|
||||
hostingType?: McpHostingType;
|
||||
authHeaders?: McpAuthHeaderDef[];
|
||||
}
|
||||
|
||||
export interface McpToolDef {
|
||||
|
|
@ -58,12 +59,24 @@ export interface McpEnvVarDef {
|
|||
isRequired?: boolean; // from registry, but treat all as optional in UI
|
||||
}
|
||||
|
||||
export interface McpAuthHeaderDef {
|
||||
key: string;
|
||||
description?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
valueTemplate?: string;
|
||||
}
|
||||
|
||||
// ── HTTP headers (for auth/config of HTTP/SSE servers) ─────────────────────
|
||||
|
||||
export interface McpHeaderDef {
|
||||
key: string;
|
||||
value: string;
|
||||
secret?: boolean; // true = mask in UI, don't log
|
||||
description?: string;
|
||||
isRequired?: boolean;
|
||||
valueTemplate?: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
// ── Installed state (from ~/.claude.json / .mcp.json) ──────────────────────
|
||||
|
|
|
|||
|
|
@ -143,6 +143,12 @@ describe('OfficialMcpRegistryService', () => {
|
|||
const adAdvisor = result.servers.find((s) => s.id === 'ai.adadvisor/mcp-server');
|
||||
|
||||
expect(adAdvisor?.requiresAuth).toBe(true);
|
||||
expect(adAdvisor?.authHeaders).toHaveLength(1);
|
||||
expect(adAdvisor?.authHeaders?.[0]).toMatchObject({
|
||||
key: 'Authorization',
|
||||
isRequired: true,
|
||||
isSecret: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('collects environment variables', async () => {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ interface RunLike {
|
|||
provisioningComplete: boolean;
|
||||
leadMsgSeq: number;
|
||||
pendingToolCalls: { name: string; preview: string }[];
|
||||
pendingDirectCrossTeamSendRefresh: boolean;
|
||||
lastLeadTextEmitMs: number;
|
||||
leadRelayCapture: null;
|
||||
silentUserDmForward: null;
|
||||
|
|
@ -156,6 +157,7 @@ function attachRun(
|
|||
provisioningComplete: opts?.provisioningComplete ?? false,
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCalls: [],
|
||||
pendingDirectCrossTeamSendRefresh: false,
|
||||
lastLeadTextEmitMs: 0,
|
||||
leadRelayCapture: null,
|
||||
silentUserDmForward: null,
|
||||
|
|
@ -588,6 +590,46 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
|||
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes sentMessages history after direct MCP cross_team_send succeeds', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
||||
service.setTeamChangeEmitter(emitter);
|
||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||
|
||||
callHandleStreamJsonMessage(service, run, {
|
||||
type: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'mcp__agent-teams__cross_team_send',
|
||||
input: {
|
||||
teamName: 'my-team',
|
||||
toTeam: 'team-best',
|
||||
text: 'Прямой вызов MCP.',
|
||||
summary: 'Direct MCP send',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(run.pendingDirectCrossTeamSendRefresh).toBe(true);
|
||||
|
||||
callHandleStreamJsonMessage(service, run, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
});
|
||||
|
||||
expect(run.pendingDirectCrossTeamSendRefresh).toBe(false);
|
||||
expect(emitter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'inbox',
|
||||
teamName: 'my-team',
|
||||
detail: 'sentMessages.json',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks native cross-team teammate-message deliveries as read and restores reply hints', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
|
|
@ -627,6 +669,48 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('suppresses native duplicate cross-team teammate-message after recent relay delivery', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
const content =
|
||||
'<cross-team from="other-team.team-lead" depth="0" conversationId="conv-native-dup" replyToConversationId="conv-native-dup" />\nПовторная доставка.';
|
||||
seedLeadInbox('my-team', [
|
||||
{
|
||||
from: 'other-team.team-lead',
|
||||
to: 'team-lead',
|
||||
text: content,
|
||||
timestamp: '2026-03-10T21:43:00.000Z',
|
||||
read: false,
|
||||
source: 'cross_team',
|
||||
messageId: 'm-native-cross-team-dup',
|
||||
conversationId: 'conv-native-dup',
|
||||
replyToConversationId: 'conv-native-dup',
|
||||
},
|
||||
]);
|
||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||
|
||||
(service as any).rememberRecentCrossTeamLeadDeliveryMessageIds('my-team', [
|
||||
'm-native-cross-team-dup',
|
||||
]);
|
||||
|
||||
callHandleStreamJsonMessage(service, run, {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: `<teammate-message teammate_id="other-team.team-lead" color="purple" summary="Cross-team reply">${content}</teammate-message>`,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const updatedInbox = JSON.parse(
|
||||
hoisted.files.get('/mock/teams/my-team/inboxes/team-lead.json') ?? '[]'
|
||||
) as Array<{ read?: boolean }>;
|
||||
expect(updatedInbox[0]?.read).toBe(true);
|
||||
});
|
||||
|
||||
expect(run.activeCrossTeamReplyHints).toEqual([]);
|
||||
});
|
||||
|
||||
it('rescues mistaken cross_team_send recipients into actual cross-team replies', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not custom-relay incoming cross-team lead inbox messages', async () => {
|
||||
it('includes explicit cross-team reply instructions in lead relay prompts', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
|
|
@ -373,17 +373,24 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayed = await service.relayLeadInboxMessages(teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
expect(run?.leadRelayCapture).toBeTruthy();
|
||||
|
||||
expect(relayed).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('Source: cross_team');
|
||||
expect(payload).toContain('Cross-team conversationId: conv-explicit');
|
||||
expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"');
|
||||
expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"');
|
||||
expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"');
|
||||
|
||||
const updatedInbox = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
|
||||
) as Array<{ messageId?: string; read?: boolean }>;
|
||||
expect(updatedInbox).toHaveLength(1);
|
||||
expect(updatedInbox[0]?.messageId).toBe('m-cross-team-explicit');
|
||||
expect(updatedInbox[0]?.read).toBe(false);
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Replying properly.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await relayPromise;
|
||||
});
|
||||
|
||||
it('does not relay cross-team sender copies back into the live lead', async () => {
|
||||
|
|
@ -482,7 +489,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('leaves later cross-team follow-up messages for the native teammate-message path', async () => {
|
||||
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);
|
||||
|
|
@ -522,17 +529,18 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayed = await service.relayLeadInboxMessages(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' });
|
||||
|
||||
expect(relayed).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
const updatedInbox = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
|
||||
) as Array<{ messageId?: string; read?: boolean }>;
|
||||
expect(updatedInbox).toHaveLength(3);
|
||||
expect(updatedInbox[2]?.messageId).toBe('m-cross-team-followup');
|
||||
expect(updatedInbox[2]?.read).toBe(false);
|
||||
const relayed = await relayPromise;
|
||||
expect(relayed).toBe(1);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('relays unread teammate inbox messages through the live team process', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue