From 71143db3acd9b683b660cc2f0ad6d68fd8d64664 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 14:48:55 +0200 Subject: [PATCH] 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. --- .../src/internal/crossTeamProtocol.js | 31 +++- docs/research/lead-thought-msg-scroll.md | 89 +++++++++++ src/main/services/team/CrossTeamService.ts | 16 +- .../services/team/TeamProvisioningService.ts | 141 ++++++++++++++++-- .../components/settings/SettingsTabs.tsx | 7 +- .../components/settings/SettingsView.tsx | 3 - .../settings/sections/ConnectionSection.tsx | 8 + .../settings/sections/WorkspaceSection.tsx | 45 ++++-- .../components/team/TeamDetailView.tsx | 5 +- .../components/team/activity/ActivityItem.tsx | 11 +- .../components/team/kanban/KanbanBoard.tsx | 14 +- .../team/messages/MessageComposer.tsx | 30 +++- src/renderer/hooks/useComposerDraft.ts | 30 +++- src/renderer/services/composerDraftStorage.ts | 3 +- src/renderer/store/slices/teamSlice.ts | 6 +- src/shared/constants/crossTeam.ts | 71 +++++++-- .../services/team/CrossTeamService.test.ts | 33 ++++ .../services/team/TeamInboxWriter.test.ts | 4 +- ...eamProvisioningServiceLiveMessages.test.ts | 38 +++++ .../team/TeamProvisioningServiceRelay.test.ts | 82 +++++++++- test/shared/constants/crossTeam.test.ts | 6 +- 21 files changed, 583 insertions(+), 90 deletions(-) create mode 100644 docs/research/lead-thought-msg-scroll.md diff --git a/agent-teams-controller/src/internal/crossTeamProtocol.js b/agent-teams-controller/src/internal/crossTeamProtocol.js index 7dfa9460..e2caa1d0 100644 --- a/agent-teams-controller/src/internal/crossTeamProtocol.js +++ b/agent-teams-controller/src/internal/crossTeamProtocol.js @@ -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, '>'); +} + 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, diff --git a/docs/research/lead-thought-msg-scroll.md b/docs/research/lead-thought-msg-scroll.md new file mode 100644 index 00000000..bbf42872 --- /dev/null +++ b/docs/research/lead-thought-msg-scroll.md @@ -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 | \ No newline at end of file diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 49764a29..5d96f1fd 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -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)) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8c8b704a..9ff2679a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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(); private readonly activeByTeam = new Map(); @@ -1081,6 +1083,7 @@ export class TeamProvisioningService { private readonly relayedLeadInboxMessageIds = new Map>(); private readonly memberInboxRelayInFlight = new Map>(); private readonly relayedMemberInboxMessageIds = new Map>(); + private readonly pendingCrossTeamFirstReplies = new Map>(); private readonly liveLeadProcessMessages = new Map(); 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(); + 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 { + const teamMap = this.pendingCrossTeamFirstReplies.get(teamName.trim()); + if (!teamMap) return new Set(); + 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(); + } + 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(); + const latestReadInboundByConversation = new Map(); + 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(); 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}:`)) { diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx index 4995d64e..ddffa206 100644 --- a/src/renderer/components/settings/SettingsTabs.tsx +++ b/src/renderer/components/settings/SettingsTabs.tsx @@ -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 ( -
+
{visibleTabs.map((tab) => { const Icon = tab.icon; const isActive = activeSection === tab.id; diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index cf66c06f..3aff54be 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -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' && } - {activeSection === 'workspace' && } - {activeSection === 'notifications' && ( {
)} + + {/* Workspace Profiles */} +
+ +
); }; diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx index aab157af..ee6eb900 100644 --- a/src/renderer/components/settings/sections/WorkspaceSection.tsx +++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx @@ -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} - - + + + + + + Edit profile + + + + + + + + Delete profile + + ) )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e1f5d078..733f387a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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, diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 56c5af74..b3e1050f 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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 {part}; + if (!match) return {part}; const taskId = match[1]; return (