diff --git a/src/features/localization/renderer/locales/en/common.json b/src/features/localization/renderer/locales/en/common.json index 0b020a1c..ae7b92ad 100644 --- a/src/features/localization/renderer/locales/en/common.json +++ b/src/features/localization/renderer/locales/en/common.json @@ -833,7 +833,7 @@ "team": "Team", "sessions": "Sessions", "kanban": "Kanban", - "claudeLogs": "Claude Logs", + "claudeLogs": "Logs", "messages": "Messages" } }, diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json index 4e31e250..bb5fd673 100644 --- a/src/features/localization/renderer/locales/en/team.json +++ b/src/features/localization/renderer/locales/en/team.json @@ -1519,7 +1519,7 @@ }, "claudeLogs": { "filter": { - "ariaLabel": "Filter Claude logs", + "ariaLabel": "Filter logs", "tooltip": "Filter logs", "sections": { "stream": "Stream", @@ -1558,7 +1558,15 @@ "openFullscreen": "Open fullscreen logs", "fullscreen": "Fullscreen", "viewingFullscreen": "Viewing in fullscreen mode", - "logsTitle": "Logs" + "logsTitle": "Logs", + "sourceSelect": { + "placeholder": "Select log source...", + "searchPlaceholder": "Search log sources...", + "emptyMessage": "No log sources found.", + "ariaLabel": "Log source", + "leadLabel": "Lead", + "selectSourceEmpty": "Select a log source." + } }, "agentGraph": { "popover": { @@ -2054,6 +2062,10 @@ }, "joining": { "teammatesStillJoining": "{{count}} teammates still joining", + "teammatesStillJoining_one": "{{count}} teammate still joining", + "teammatesStillJoining_few": "{{count}} teammates still joining", + "teammatesStillJoining_many": "{{count}} teammates still joining", + "teammatesStillJoining_other": "{{count}} teammates still joining", "teammatesConfirmedRatio": "{{count}}/{{total}} teammates confirmed" }, "ready": { diff --git a/src/features/localization/renderer/locales/ru/common.json b/src/features/localization/renderer/locales/ru/common.json index 74068192..7bbb518b 100644 --- a/src/features/localization/renderer/locales/ru/common.json +++ b/src/features/localization/renderer/locales/ru/common.json @@ -833,7 +833,7 @@ "team": "Команда", "sessions": "Сессии", "kanban": "Канбан", - "claudeLogs": "Логи Claude", + "claudeLogs": "Логи", "messages": "Сообщения" } }, diff --git a/src/features/localization/renderer/locales/ru/team.json b/src/features/localization/renderer/locales/ru/team.json index 357a7b52..4eb9fd8e 100644 --- a/src/features/localization/renderer/locales/ru/team.json +++ b/src/features/localization/renderer/locales/ru/team.json @@ -1519,8 +1519,8 @@ }, "claudeLogs": { "filter": { - "ariaLabel": "Фильтровать Claude logs", - "tooltip": "Фильтровать logs", + "ariaLabel": "Фильтровать логи", + "tooltip": "Фильтровать логи", "sections": { "stream": "Stream", "content": "Content" @@ -1558,7 +1558,15 @@ "openFullscreen": "Открыть логи на весь экран", "fullscreen": "На весь экран", "viewingFullscreen": "Просмотр в полноэкранном режиме", - "logsTitle": "Логи" + "logsTitle": "Логи", + "sourceSelect": { + "placeholder": "Выберите источник логов...", + "searchPlaceholder": "Искать источники логов...", + "emptyMessage": "Источники логов не найдены.", + "ariaLabel": "Источник логов", + "leadLabel": "Лид", + "selectSourceEmpty": "Выберите источник логов." + } }, "agentGraph": { "popover": { @@ -2054,6 +2062,10 @@ }, "joining": { "teammatesStillJoining": "{{count}} участник(ов) ещё подключается", + "teammatesStillJoining_one": "{{count}} участник ещё подключается", + "teammatesStillJoining_few": "{{count}} участника ещё подключаются", + "teammatesStillJoining_many": "{{count}} участников ещё подключается", + "teammatesStillJoining_other": "{{count}} участника(ов) ещё подключается", "teammatesConfirmedRatio": "{{count}}/{{total}} участников подтверждено" }, "ready": { diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts index f77efa56..884e9862 100644 --- a/src/features/localization/renderer/resources.d.ts +++ b/src/features/localization/renderer/resources.d.ts @@ -289,7 +289,7 @@ export default interface Resources { refreshSessionWithShortcut: 'Refresh Session ({{shortcut}})'; resizeSidebar: 'Resize sidebar'; sections: { - claudeLogs: 'Claude Logs'; + claudeLogs: 'Logs'; kanban: 'Kanban'; messages: 'Messages'; sessions: 'Sessions'; @@ -3162,7 +3162,7 @@ export default interface Resources { reset: 'Reset'; save: 'Save'; }; - ariaLabel: 'Filter Claude logs'; + ariaLabel: 'Filter logs'; kinds: { output: 'Output'; thinking: 'Thinking'; @@ -3194,6 +3194,14 @@ export default interface Resources { rawLinesCaptured: '{{count}} captured'; searchPlaceholder: 'Search logs...'; showMore: 'Show more'; + sourceSelect: { + ariaLabel: 'Log source'; + emptyMessage: 'No log sources found.'; + leadLabel: 'Lead'; + placeholder: 'Select log source...'; + searchPlaceholder: 'Search log sources...'; + selectSourceEmpty: 'Select a log source.'; + }; teamNotRunning: 'Team is not running.'; viewingFullscreen: 'Viewing in fullscreen mode'; }; @@ -4495,6 +4503,10 @@ export default interface Resources { joining: { teammatesConfirmedRatio: '{{count}}/{{total}} teammates confirmed'; teammatesStillJoining: '{{count}} teammates still joining'; + teammatesStillJoining_few: '{{count}} teammates still joining'; + teammatesStillJoining_many: '{{count}} teammates still joining'; + teammatesStillJoining_one: '{{count}} teammate still joining'; + teammatesStillJoining_other: '{{count}} teammates still joining'; }; nameListWithMore: '{{names}}, +{{count}} more'; namedPendingDiagnostic: '{{label}}: {{names}}'; diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index d1b7c22d..e5fbb883 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -2,7 +2,32 @@ import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types'; -type ProviderConnectionTranslator = unknown; +type ProviderConnectionTranslator = object; + +function interpolateProviderConnectionFallback( + value: string, + options?: Record +): string { + if (!options) { + return value; + } + + return value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match: string, optionKey: string) => { + const optionValue = options[optionKey]; + if (optionValue === undefined || optionValue === null) { + return match; + } + if ( + typeof optionValue === 'string' || + typeof optionValue === 'number' || + typeof optionValue === 'boolean' || + typeof optionValue === 'bigint' + ) { + return String(optionValue); + } + return match; + }); +} function translateProviderConnection( t: ProviderConnectionTranslator | undefined, @@ -10,14 +35,20 @@ function translateProviderConnection( fallback: string, options?: Record ): string { + const interpolatedFallback = interpolateProviderConnectionFallback(fallback, options); if (!t) { - return fallback; + return interpolatedFallback; } - return (t as (translationKey: string, options?: Record) => string)(key, { - defaultValue: fallback, - ...options, - }); + const translated = (t as (translationKey: string, options?: Record) => string)( + key, + { + ...options, + defaultValue: fallback, + } + ); + + return interpolateProviderConnectionFallback(translated, options); } const CODEX_NATIVE_LABEL = 'Codex native'; diff --git a/src/renderer/components/team/ClaudeLogsDialog.tsx b/src/renderer/components/team/ClaudeLogsDialog.tsx deleted file mode 100644 index d93f671a..00000000 --- a/src/renderer/components/team/ClaudeLogsDialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * ClaudeLogsDialog - * - * Fullscreen-style dialog for viewing Claude logs in a large viewport. - * Uses the same ClaudeLogsPanel as the compact sidebar but with more space. - * Only one CliLogsRichView is mounted at a time — when this dialog is open, - * the compact panel hides its log viewer. - */ - -import React from 'react'; - -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog'; -import { Terminal } from 'lucide-react'; - -import { ClaudeLogsPanel } from './ClaudeLogsPanel'; - -import type { ClaudeLogsController } from './useClaudeLogsController'; - -// ============================================================================= -// Props -// ============================================================================= - -interface ClaudeLogsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - ctrl: ClaudeLogsController; -} - -// ============================================================================= -// Component -// ============================================================================= - -export const ClaudeLogsDialog = ({ - open, - onOpenChange, - ctrl, -}: ClaudeLogsDialogProps): React.JSX.Element => { - return ( - - - - - - - - Claude logs - {ctrl.badge != null && ( - - ({ctrl.badge}) - - )} - {ctrl.online && ( - - - - - )} - - - -
- -
-
-
- ); -}; diff --git a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx index 29441f41..da7235cc 100644 --- a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx +++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx @@ -1,24 +1,18 @@ import { useEffect, useMemo, useState } from 'react'; +import { useAppTranslation } from '@features/localization/renderer'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { useAppTranslation } from '@features/localization/renderer'; import { Filter } from 'lucide-react'; -export type ClaudeLogStream = 'stdout' | 'stderr'; -export type ClaudeLogKind = 'output' | 'thinking' | 'tool'; - -export interface ClaudeLogsFilterState { - streams: Set; - kinds: Set; -} - -export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = { - streams: new Set(['stdout', 'stderr']), - kinds: new Set(['output', 'thinking', 'tool']), -}; +import { + type ClaudeLogKind, + type ClaudeLogsFilterState, + type ClaudeLogStream, + DEFAULT_CLAUDE_LOGS_FILTER, +} from './claudeLogsFilterState'; function setEquals(a: Set, b: Set): boolean { if (a.size !== b.size) return false; diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx index 6ea0b8f5..8042d6b3 100644 --- a/src/renderer/components/team/ClaudeLogsPanel.tsx +++ b/src/renderer/components/team/ClaudeLogsPanel.tsx @@ -30,6 +30,7 @@ interface ClaudeLogsPanelProps { /** Extra className for the panel wrapper. */ className?: string; compactMetaInTooltip?: boolean; + toolbarControlsStart?: React.ReactNode; } // ============================================================================= @@ -42,6 +43,7 @@ export const ClaudeLogsPanel = ({ viewerMaxHeight, className, compactMetaInTooltip = false, + toolbarControlsStart, }: ClaudeLogsPanelProps): React.JSX.Element => { const { t } = useAppTranslation('team'); const { @@ -88,7 +90,10 @@ export const ClaudeLogsPanel = ({ t('claudeLogs.teamNotRunning') )} -
+
+ {toolbarControlsStart ? ( +
{toolbarControlsStart}
+ ) : null} {data.total > 0 ? ( <>
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 51aa2a4e..8e4ee741 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -1,17 +1,34 @@ -import { memo, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { Button } from '@renderer/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react'; +import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; +import { isLeadMember } from '@shared/utils/leadDetection'; +import { Brain, Expand, MessageSquare, Terminal, Wrench } from 'lucide-react'; -import { ClaudeLogsDialog } from './ClaudeLogsDialog'; +import { MemberLogStreamWithLegacyFallback } from './members/MemberLogStreamWithLegacyFallback'; import { ClaudeLogsPanel } from './ClaudeLogsPanel'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; +import { + buildSelectableLogMembers, + formatMemberLogSourceDescription, + formatMemberLogSourceLabel, + getMemberNameFromLogSourceKey, + LEAD_LOG_SOURCE_KEY, + memberLogSourceKey, + normalizeMemberLogSourceName, + resolveLeadLogMember, +} from './teamLogSources'; import { useClaudeLogsController } from './useClaudeLogsController'; +import type { TeamLogSourceKey } from './teamLogSources'; import type { LastLogPreview } from './useClaudeLogsController'; +import type { ResolvedTeamMember } from '@shared/types'; // ============================================================================= // Constants @@ -79,6 +96,181 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E ); }; +const TeamLogsSourceSelector = ({ + leadMember, + members, + selectedKey, + onChange, + className, +}: { + leadMember: ResolvedTeamMember; + members: readonly ResolvedTeamMember[]; + selectedKey: TeamLogSourceKey; + onChange: (key: TeamLogSourceKey) => void; + className?: string; +}): React.JSX.Element | null => { + const { t } = useAppTranslation('team'); + const sourceMembers = useMemo(() => [leadMember, ...members], [leadMember, members]); + const selectedMemberName = + selectedKey === LEAD_LOG_SOURCE_KEY + ? leadMember.name + : getMemberNameFromLogSourceKey(selectedKey); + + if (sourceMembers.length <= 1) return null; + + return ( +
+ { + const selectedMember = sourceMembers.find((member) => member.name === memberName); + if (!selectedMember || isLeadMember(selectedMember)) { + onChange(LEAD_LOG_SOURCE_KEY); + return; + } + onChange(memberLogSourceKey(selectedMember.name)); + }} + placeholder={t('claudeLogs.sourceSelect.placeholder')} + searchPlaceholder={t('claudeLogs.sourceSelect.searchPlaceholder')} + emptyMessage={t('claudeLogs.sourceSelect.emptyMessage')} + ariaLabel={t('claudeLogs.sourceSelect.ariaLabel')} + getMemberLabel={(member) => + isLeadMember(member) + ? t('claudeLogs.sourceSelect.leadLabel') + : formatMemberLogSourceLabel(member) + } + getMemberDescription={formatMemberLogSourceDescription} + /> +
+ ); +}; + +const MemberSourcePill = ({ member }: { member: ResolvedTeamMember }): React.JSX.Element => ( + + {formatMemberLogSourceLabel(member)} + +); + +const MemberLogsSourcePanel = ({ + teamName, + member, + enabled, + maxHeight, +}: { + teamName: string; + member: ResolvedTeamMember; + enabled: boolean; + maxHeight?: number; +}): React.JSX.Element => { + const content = ( + + ); + + if (maxHeight === undefined) { + return content; + } + + return ( +
+ {content} +
+ ); +}; + +const TeamLogsDialog = ({ + open, + onOpenChange, + teamName, + leadMember, + members, + selectedKey, + onSourceChange, + showingLeadLogs, + ctrl, + selectedMember, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + teamName: string; + leadMember: ResolvedTeamMember; + members: readonly ResolvedTeamMember[]; + selectedKey: TeamLogSourceKey; + onSourceChange: (key: TeamLogSourceKey) => void; + showingLeadLogs: boolean; + ctrl: ReturnType; + selectedMember: ResolvedTeamMember | null; +}): React.JSX.Element => { + const { t } = useAppTranslation('team'); + const sourceSelector = + members.length > 0 ? ( + + ) : null; + + return ( + + + + + + + + {t('claudeLogs.logsTitle')} + {showingLeadLogs && ctrl.badge != null ? ( + + ({ctrl.badge}) + + ) : null} + {showingLeadLogs && ctrl.online ? ( + + + + + ) : null} + + + +
+ {showingLeadLogs ? ( + + ) : selectedMember ? ( + <> + {sourceSelector ? ( +
{sourceSelector}
+ ) : null} + + + ) : ( +
+ {t('claudeLogs.sourceSelect.selectSourceEmpty')} +
+ )} +
+
+
+ ); +}; + // ============================================================================= // Main component // ============================================================================= @@ -90,22 +282,79 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({ onOpenChange, }: ClaudeLogsSectionProps): React.JSX.Element { const { t } = useAppTranslation('team'); - const ctrl = useClaudeLogsController(teamName); + const teamMembers = useStore((state) => selectResolvedMembersForTeamName(state, teamName)); + const [selectedSourceState, setSelectedSourceState] = useState<{ + teamName: string; + sourceKey: TeamLogSourceKey; + }>(() => ({ teamName, sourceKey: LEAD_LOG_SOURCE_KEY })); + const selectedSourceKey = + selectedSourceState.teamName === teamName ? selectedSourceState.sourceKey : LEAD_LOG_SOURCE_KEY; + const setSelectedSourceKey = useCallback( + (sourceKey: TeamLogSourceKey) => { + setSelectedSourceState({ teamName, sourceKey }); + }, + [teamName] + ); + const leadMember = useMemo(() => resolveLeadLogMember(teamMembers), [teamMembers]); + const selectableMembers = useMemo(() => buildSelectableLogMembers(teamMembers), [teamMembers]); + const selectedMemberName = getMemberNameFromLogSourceKey(selectedSourceKey); + const selectedMemberSourceName = selectedMemberName + ? normalizeMemberLogSourceName(selectedMemberName) + : null; + const selectedMember = useMemo( + () => + selectedMemberSourceName + ? (selectableMembers.find( + (member) => normalizeMemberLogSourceName(member.name) === selectedMemberSourceName + ) ?? null) + : null, + [selectableMembers, selectedMemberSourceName] + ); + const effectiveSelectedSourceKey = + selectedSourceKey === LEAD_LOG_SOURCE_KEY + ? LEAD_LOG_SOURCE_KEY + : selectedMember + ? memberLogSourceKey(selectedMember.name) + : LEAD_LOG_SOURCE_KEY; + const showingLeadLogs = effectiveSelectedSourceKey === LEAD_LOG_SOURCE_KEY; + const ctrl = useClaudeLogsController(teamName, { enabled: showingLeadLogs }); const [dialogOpen, setDialogOpen] = useState(false); const isSidebar = position === 'sidebar'; - const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error; + const showHeaderSkeleton = + showingLeadLogs && ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error; + + useEffect(() => { + if (selectedSourceState.teamName !== teamName) { + setSelectedSourceState({ teamName, sourceKey: LEAD_LOG_SOURCE_KEY }); + } + }, [selectedSourceState.teamName, teamName]); + + useEffect(() => { + if (selectedSourceKey === LEAD_LOG_SOURCE_KEY) return; + if (selectedMember) { + const canonicalSourceKey = memberLogSourceKey(selectedMember.name); + if (selectedSourceKey !== canonicalSourceKey) { + setSelectedSourceKey(canonicalSourceKey); + } + return; + } + setSelectedSourceKey(LEAD_LOG_SOURCE_KEY); + }, [selectedMember, selectedSourceKey, setSelectedSourceKey]); const sectionHeaderExtra = useMemo( () => ( - {ctrl.online ? ( + {showingLeadLogs && ctrl.online ? ( ) : null} - {ctrl.lastLogPreview ? : null} + {showingLeadLogs && ctrl.lastLogPreview ? ( + + ) : null} + {!showingLeadLogs && selectedMember ? : null} {showHeaderSkeleton ? ( @@ -116,9 +365,18 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({ ) : null} ), - [ctrl.online, ctrl.lastLogPreview, isSidebar, showHeaderSkeleton] + [ + ctrl.online, + ctrl.lastLogPreview, + isSidebar, + selectedMember, + showingLeadLogs, + showHeaderSkeleton, + ] ); + const canOpenFullscreen = showingLeadLogs ? ctrl.data.total > 0 : selectedMember !== null; + const afterBadge = showHeaderSkeleton ? ( <> @@ -126,7 +384,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({ - ) : ctrl.data.total > 0 ? ( + ) : canOpenFullscreen ? (
diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 3222cbdf..0a1ee0ec 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,10 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; -import { - isMemberLogStreamUiEnabled, - MemberLogStreamSection, -} from '@features/member-log-stream/renderer'; // import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; @@ -44,7 +40,7 @@ import { MemberDetailHeader } from './MemberDetailHeader'; import { MemberDetailStats } from './MemberDetailStats'; import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; -import { MemberLogsTab } from './MemberLogsTab'; +import { MemberLogStreamWithLegacyFallback } from './MemberLogStreamWithLegacyFallback'; import { MemberMessagesTab } from './MemberMessagesTab'; import { MemberStatsTab } from './MemberStatsTab'; import { MemberTasksTab } from './MemberTasksTab'; @@ -199,7 +195,6 @@ export const MemberDetailDialog = ({ const [activeTab, setActiveTab] = useState(initialTab); const [restarting, setRestarting] = useState(false); const [restartError, setRestartError] = useState(null); - const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false); const runtimeSummary = useMemo( () => @@ -273,7 +268,6 @@ export const MemberDetailDialog = ({ setActiveTab(initialTab); setRestartError(null); setRestarting(false); - setShowLegacyLogsFallback(false); }, [initialTab, member, open]); const { @@ -283,7 +277,6 @@ export const MemberDetailDialog = ({ } = useMemberStats(teamName, member?.name ?? null); const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null; - const memberLogStreamEnabled = isMemberLogStreamUiEnabled(); if (!member) return null; @@ -399,26 +392,11 @@ export const MemberDetailDialog = ({ /> - {memberLogStreamEnabled ? ( -
- - {showLegacyLogsFallback ? ( -
-
- {t('members.detail.legacyLogsFallback')} -
- -
- ) : null} -
- ) : ( - - )} +
diff --git a/src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx b/src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx new file mode 100644 index 00000000..54f77b51 --- /dev/null +++ b/src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { useAppTranslation } from '@features/localization/renderer'; +import { + isMemberLogStreamUiEnabled, + MemberLogStreamSection, +} from '@features/member-log-stream/renderer'; + +import { MemberLogsTab } from './MemberLogsTab'; + +import type { ResolvedTeamMember } from '@shared/types'; + +interface MemberLogStreamWithLegacyFallbackProps { + teamName: string; + member: ResolvedTeamMember; + enabled?: boolean; +} + +export const MemberLogStreamWithLegacyFallback = ({ + teamName, + member, + enabled = true, +}: Readonly): React.JSX.Element => { + const { t } = useAppTranslation('team'); + const streamUiEnabled = isMemberLogStreamUiEnabled(); + const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false); + + useEffect(() => { + setShowLegacyLogsFallback(false); + }, [member.name, streamUiEnabled, teamName]); + + if (!streamUiEnabled) { + return ; + } + + return ( +
+ + {showLegacyLogsFallback ? ( +
+
+ {t('members.detail.legacyLogsFallback')} +
+ +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/team/sidebar/teamSidebarUiState.ts b/src/renderer/components/team/sidebar/teamSidebarUiState.ts index 91b6107a..af6f34a0 100644 --- a/src/renderer/components/team/sidebar/teamSidebarUiState.ts +++ b/src/renderer/components/team/sidebar/teamSidebarUiState.ts @@ -1,6 +1,5 @@ -import { DEFAULT_CLAUDE_LOGS_FILTER } from '../ClaudeLogsFilterPopover'; +import { type ClaudeLogsFilterState, DEFAULT_CLAUDE_LOGS_FILTER } from '../claudeLogsFilterState'; -import type { ClaudeLogsFilterState } from '../ClaudeLogsFilterPopover'; import type { ClaudeLogsViewerState } from '../CliLogsRichView'; import type { MessagesFilterState } from '../messages/MessagesFilterPopover'; diff --git a/src/renderer/components/team/teamLogSources.ts b/src/renderer/components/team/teamLogSources.ts new file mode 100644 index 00000000..b5d04efc --- /dev/null +++ b/src/renderer/components/team/teamLogSources.ts @@ -0,0 +1,74 @@ +import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { ResolvedTeamMember } from '@shared/types'; + +export const LEAD_LOG_SOURCE_KEY = 'lead'; + +const FALLBACK_LEAD_LOG_MEMBER: ResolvedTeamMember = { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, +}; + +export type TeamLogSourceKey = typeof LEAD_LOG_SOURCE_KEY | `member:${string}`; + +export function memberLogSourceKey(memberName: string): TeamLogSourceKey { + return `member:${memberName}`; +} + +export function getMemberNameFromLogSourceKey(sourceKey: TeamLogSourceKey): string | null { + if (sourceKey === LEAD_LOG_SOURCE_KEY) return null; + return sourceKey.slice('member:'.length); +} + +export function formatMemberLogSourceLabel(member: ResolvedTeamMember): string { + return member.removedAt ? `${member.name} (removed)` : member.name; +} + +export function formatMemberLogSourceDescription(member: ResolvedTeamMember): string | null { + if (isLeadMember(member)) return 'Team Lead'; + if (member.removedAt) return 'Removed'; + return formatAgentRole(member.role) ?? formatAgentRole(member.agentType) ?? null; +} + +export function normalizeMemberLogSourceName(memberName: string): string { + return memberName.trim().toLowerCase(); +} + +export function buildSelectableLogMembers( + members: readonly ResolvedTeamMember[] +): ResolvedTeamMember[] { + const sourceByName = new Map< + string, + { + member: ResolvedTeamMember; + index: number; + } + >(); + + members.forEach((member, index) => { + const sourceName = normalizeMemberLogSourceName(member.name); + if (!sourceName || sourceName === 'user' || isLeadMember(member)) return; + + const existing = sourceByName.get(sourceName); + if (!existing || (existing.member.removedAt && !member.removedAt)) { + sourceByName.set(sourceName, { member, index: existing?.index ?? index }); + } + }); + + return [...sourceByName.values()] + .sort((left, right) => left.index - right.index) + .map((entry) => entry.member); +} + +export function resolveLeadLogMember(members: readonly ResolvedTeamMember[]): ResolvedTeamMember { + const leadMembers = members.filter((member) => isLeadMember(member)); + return ( + leadMembers.find((member) => !member.removedAt) ?? leadMembers[0] ?? FALLBACK_LEAD_LOG_MEMBER + ); +} diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index 1f75aa5e..25b2bc87 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -17,9 +17,8 @@ import { getTeamClaudeLogsSidebarUiState, setTeamClaudeLogsSidebarUiState, } from './sidebar/teamSidebarUiState'; -import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; +import { type ClaudeLogsFilterState, DEFAULT_CLAUDE_LOGS_FILTER } from './claudeLogsFilterState'; -import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsViewerState } from './CliLogsRichView'; import type { TeamClaudeLogsResponse } from '@shared/types'; @@ -367,7 +366,11 @@ function filterStreamJsonText( // Hook // ============================================================================= -export function useClaudeLogsController(teamName: string): ClaudeLogsController { +export function useClaudeLogsController( + teamName: string, + options: { enabled?: boolean } = {} +): ClaudeLogsController { + const enabled = options.enabled ?? true; const isAlive = useStore((s) => s.selectedTeamName === teamName ? (s.selectedTeamData?.isAlive ?? false) : false ); @@ -407,6 +410,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController const logContainerRef = useRef(null); const committedRef = useRef({ lines: [], total: 0, hasMore: false }); const pendingCountRef = useRef(0); + const pendingPollingFetchRef = useRef<(() => void) | null>(null); // ── Reset on team change ────────────────────────────────────────────── useEffect(() => { @@ -447,6 +451,13 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController useEffect(() => { let cancelled = false; + if (!enabled) { + setLoading(false); + return () => { + cancelled = true; + }; + } + const computeNewCount = ( committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse @@ -460,8 +471,17 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController return Math.max(0, diff); }; - const fetchLogs = async (): Promise => { - if (inFlightRef.current) return; + const fetchLogs = async (options: { queueIfBusy?: boolean } = {}): Promise => { + if (inFlightRef.current) { + if (options.queueIfBusy) { + pendingPollingFetchRef.current = () => { + if (!cancelled) { + void fetchLogs(); + } + }; + } + return; + } inFlightRef.current = true; try { setLoading(true); @@ -483,20 +503,27 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController setError(e instanceof Error ? e.message : String(e)); } finally { inFlightRef.current = false; - if (!cancelled) setLoading(false); + const pendingFetch = pendingPollingFetchRef.current; + pendingPollingFetchRef.current = null; + if (pendingFetch) { + pendingFetch(); + } else if (!cancelled) { + setLoading(false); + } } }; - void fetchLogs(); - const id = window.setInterval(() => void fetchLogs(), POLL_MS); + void fetchLogs({ queueIfBusy: true }); + const id = window.setInterval(() => void fetchLogs({ queueIfBusy: true }), POLL_MS); return () => { cancelled = true; window.clearInterval(id); }; - }, [teamName, loadedCount]); + }, [enabled, teamName, loadedCount]); // ── Load older logs ─────────────────────────────────────────────────── const loadOlderLogs = useCallback(async (): Promise => { + if (!enabled) return; if (loadingMoreRef.current || inFlightRef.current) return; const current = committedRef.current; @@ -526,7 +553,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController loadingMoreRef.current = false; setLoadingMore(false); } - }, [teamName]); + }, [enabled, teamName]); // ── Auto-load when content fits in container ────────────────────────── const isNearBottom = useCallback( @@ -550,6 +577,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController // ── Apply pending logs ──────────────────────────────────────────────── const applyPending = useCallback(async (): Promise => { + if (!enabled) return; if (applyingPendingRef.current) return; applyingPendingRef.current = true; @@ -575,7 +603,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController } finally { applyingPendingRef.current = false; } - }, [loadedCount, pending, teamName]); + }, [enabled, loadedCount, pending, teamName]); // ── Computed values ─────────────────────────────────────────────────── const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx index 4d7840c3..7c1d23a7 100644 --- a/src/renderer/components/ui/MemberSelect.tsx +++ b/src/renderer/components/ui/MemberSelect.tsx @@ -28,6 +28,11 @@ interface MemberSelectProps { size?: 'sm' | 'md'; disabled?: boolean; className?: string; + searchPlaceholder?: string; + emptyMessage?: string; + getMemberLabel?: (member: ResolvedTeamMember) => string; + getMemberDescription?: (member: ResolvedTeamMember) => string | null | undefined; + ariaLabel?: string; } const UNASSIGNED_VALUE = '__unassigned__'; @@ -41,6 +46,11 @@ export const MemberSelect = ({ size = 'sm', disabled = false, className, + searchPlaceholder, + emptyMessage, + getMemberLabel, + getMemberDescription, + ariaLabel, }: MemberSelectProps): React.JSX.Element => { const { t } = useAppTranslation('common'); const [open, setOpen] = React.useState(false); @@ -59,13 +69,26 @@ export const MemberSelect = ({ const avatarClass = size === 'md' ? 'size-6' : 'size-5'; const textSize = size === 'md' ? 'text-xs' : 'text-[10px]'; const triggerHeight = size === 'md' ? 'h-9' : 'h-8'; + const resolveMemberLabel = React.useCallback( + (member: ResolvedTeamMember): string => + getMemberLabel?.(member) ?? (member.name === 'team-lead' ? 'lead' : member.name), + [getMemberLabel] + ); + const resolveMemberDescription = React.useCallback( + (member: ResolvedTeamMember): string | null | undefined => + getMemberDescription?.(member) ?? + formatAgentRole(member.role) ?? + formatAgentRole(member.agentType), + [getMemberDescription] + ); // eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => { const resolvedColor = colorMap.get(member.name); const colors = getTeamColorSet(resolvedColor ?? ''); + const label = resolveMemberLabel(member); return ( - + - {member.name === 'team-lead' ? 'lead' : member.name} + {label} ); @@ -94,6 +117,7 @@ export const MemberSelect = ({ role="combobox" aria-expanded={open} aria-controls={listboxId} + aria-label={ariaLabel} disabled={disabled} className={cn( `flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`, @@ -129,7 +153,7 @@ export const MemberSelect = ({
@@ -139,7 +163,7 @@ export const MemberSelect = ({ onWheel={(e) => e.stopPropagation()} > - {t('members.emptyMessage')} + {emptyMessage ?? t('members.emptyMessage')} {allowUnassigned && !search.trim() ? ( { if (!search.trim()) return true; const q = search.toLowerCase(); + const label = resolveMemberLabel(m); + const description = resolveMemberDescription(m); return ( m.name.toLowerCase().includes(q) || + label.toLowerCase().includes(q) || + (description?.toLowerCase().includes(q) ?? false) || (m.role?.toLowerCase().includes(q) ?? false) || (m.agentType?.toLowerCase().includes(q) ?? false) ); @@ -171,7 +199,8 @@ export const MemberSelect = ({ const isSelected = m.name === value; const resolvedColor = colorMap.get(m.name); const colors = getTeamColorSet(resolvedColor ?? ''); - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const label = resolveMemberLabel(m); + const role = resolveMemberDescription(m); return ( - {m.name === 'team-lead' ? 'lead' : m.name} + {label} {role ? ( diff --git a/test/renderer/components/layout/TeamTabSectionNav.test.tsx b/test/renderer/components/layout/TeamTabSectionNav.test.tsx new file mode 100644 index 00000000..b9a92779 --- /dev/null +++ b/test/renderer/components/layout/TeamTabSectionNav.test.tsx @@ -0,0 +1,46 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { TeamTabSectionNav } from '@renderer/components/layout/TeamTabSectionNav'; +import { useStore } from '@renderer/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('TeamTabSectionNav', () => { + let host: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useStore.setState({ messagesPanelMode: 'inline' } as never); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('labels the logs section as Logs in the section jump menu', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const trigger = host.querySelector('button[title="Jump to section"]') as HTMLButtonElement; + await act(async () => { + trigger.click(); + await Promise.resolve(); + }); + + const menu = document.body.querySelector('[role="menu"]') as HTMLElement | null; + expect(menu?.textContent).toContain('Logs'); + expect(menu?.textContent).not.toContain('Claude Logs'); + }); +}); diff --git a/test/renderer/components/team/ClaudeLogsPanel.test.ts b/test/renderer/components/team/ClaudeLogsPanel.test.ts index 7e32a9fb..5def40e0 100644 --- a/test/renderer/components/team/ClaudeLogsPanel.test.ts +++ b/test/renderer/components/team/ClaudeLogsPanel.test.ts @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController'; @@ -106,6 +107,49 @@ describe('ClaudeLogsPanel', () => { }); }); + it('renders leading toolbar controls before the search field', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const ctrl = createController({ + isAlive: true, + data: { + lines: ['lead output'], + total: 1, + hasMore: false, + }, + filteredText: 'lead output', + }); + + await act(async () => { + root.render( + React.createElement(ClaudeLogsPanel, { + ctrl, + toolbarControlsStart: React.createElement( + 'div', + { 'data-testid': 'toolbar-source' }, + 'Lead' + ), + }) + ); + await Promise.resolve(); + }); + + const source = host.querySelector('[data-testid="toolbar-source"]'); + const search = host.querySelector('input[placeholder="Search logs..."]'); + expect(source).not.toBeNull(); + expect(search).not.toBeNull(); + expect(source?.compareDocumentPosition(search as Node)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows the offline empty state only when no logs exist', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/ClaudeLogsSection.test.ts b/test/renderer/components/team/ClaudeLogsSection.test.ts new file mode 100644 index 00000000..4d708500 --- /dev/null +++ b/test/renderer/components/team/ClaudeLogsSection.test.ts @@ -0,0 +1,1036 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController'; +import type { ResolvedTeamMember } from '@shared/types'; + +const sectionState = vi.hoisted(() => ({ + members: [] as ResolvedTeamMember[], + controllerCalls: [] as { teamName: string; enabled: boolean | undefined }[], + memberLogStreamCalls: [] as { + teamName: string; + memberName: string; + enabled: boolean | undefined; + }[], + memberLogStreamUiEnabled: true, +})); + +function createController(): ClaudeLogsController { + return { + data: { + lines: ['{"type":"assistant","content":[{"type":"text","text":"lead output"}]}'], + total: 1, + hasMore: false, + }, + loading: false, + loadingMore: false, + error: null, + pendingNewCount: 0, + isAlive: true, + filteredText: '{"type":"assistant","content":[{"type":"text","text":"lead output"}]}', + online: true, + badge: '1 raw', + showMoreVisible: false, + lastLogPreview: { type: 'output', label: 'Output', summary: 'lead output' }, + searchQuery: '', + setSearchQuery: vi.fn(), + filter: { streams: new Set(), kinds: new Set() } as ClaudeLogsController['filter'], + setFilter: vi.fn(), + filterOpen: false, + setFilterOpen: vi.fn(), + viewerState: {} as ClaudeLogsController['viewerState'], + onViewerStateChange: vi.fn(), + applyPending: vi.fn(() => Promise.resolve()), + loadOlderLogs: vi.fn(() => Promise.resolve()), + containerRefCallback: vi.fn(), + handleScroll: vi.fn(), + }; +} + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: unknown) => unknown) => selector({}), +})); + +vi.mock('@renderer/store/slices/teamSlice', () => ({ + selectResolvedMembersForTeamName: () => sectionState.members, +})); + +vi.mock('@renderer/components/team/useClaudeLogsController', () => ({ + useClaudeLogsController: (teamName: string, options?: { enabled?: boolean }) => { + sectionState.controllerCalls.push({ teamName, enabled: options?.enabled }); + return createController(); + }, +})); + +vi.mock('@renderer/components/team/ClaudeLogsPanel', () => ({ + ClaudeLogsPanel: ({ toolbarControlsStart }: { toolbarControlsStart?: React.ReactNode }) => + React.createElement( + 'div', + { 'data-testid': 'lead-logs-panel' }, + toolbarControlsStart, + 'lead-panel' + ), +})); + +vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({ + CollapsibleTeamSection: ({ + children, + afterBadge, + badge, + headerExtra, + }: { + children: React.ReactNode; + afterBadge?: React.ReactNode; + badge?: string; + headerExtra?: React.ReactNode; + }) => + React.createElement( + 'section', + null, + React.createElement('div', { 'data-testid': 'logs-header' }, badge, afterBadge, headerExtra), + children + ), +})); + +vi.mock('@renderer/components/ui/MemberSelect', () => ({ + MemberSelect: ({ + members, + value, + onChange, + getMemberLabel, + searchPlaceholder, + emptyMessage, + ariaLabel, + }: { + members: ResolvedTeamMember[]; + value: string | null; + onChange: (value: string | null) => void; + getMemberLabel?: (member: ResolvedTeamMember) => string; + searchPlaceholder?: string; + emptyMessage?: string; + ariaLabel?: string; + }) => + React.createElement( + 'div', + { + 'data-testid': 'member-select', + 'data-search-placeholder': searchPlaceholder, + 'data-empty-message': emptyMessage, + }, + React.createElement( + 'select', + { + 'aria-label': 'Log source', + 'data-trigger-aria-label': ariaLabel, + value: value ?? '', + onChange: (event: React.ChangeEvent) => + onChange(event.currentTarget.value || null), + }, + members.map((member) => + React.createElement( + 'option', + { key: member.name, value: member.name }, + getMemberLabel?.(member) ?? member.name + ) + ) + ) + ), +})); + +vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({ + MemberLogsTab: ({ memberName }: { memberName: string }) => + React.createElement('div', { 'data-testid': 'legacy-member-logs' }, memberName), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ + children, + open, + }: { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => (open ? React.createElement('div', { 'data-testid': 'logs-dialog' }, children) : null), + DialogContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogHeader: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogTitle: ({ children }: { children: React.ReactNode }) => + React.createElement('h2', null, children), +})); + +vi.mock('@features/member-log-stream/renderer', () => ({ + isMemberLogStreamUiEnabled: () => sectionState.memberLogStreamUiEnabled, + MemberLogStreamSection: ({ + teamName, + member, + enabled, + onInitialLoadErrorChange, + }: { + teamName: string; + member: ResolvedTeamMember; + enabled?: boolean; + onInitialLoadErrorChange?: (hasError: boolean) => void; + }) => { + sectionState.memberLogStreamCalls.push({ teamName, memberName: member.name, enabled }); + return React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'member-log-stream', + 'data-removed': member.removedAt ? 'true' : 'false', + onClick: () => onInitialLoadErrorChange?.(true), + }, + member.name + ); + }, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + 'aria-label': ariaLabel, + }: { + children: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; + 'aria-label'?: string; + }) => + React.createElement('button', { type: 'button', onClick, 'aria-label': ariaLabel }, children), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +import { ClaudeLogsSection } from '@renderer/components/team/ClaudeLogsSection'; + +describe('ClaudeLogsSection source filtering', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Builder', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + sectionState.controllerCalls = []; + sectionState.memberLogStreamCalls = []; + sectionState.memberLogStreamUiEnabled = true; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('shows lead logs by default and exposes teammate sources', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ClaudeLogsSection, { + teamName: 'demo-team', + sidebarViewerMaxHeight: 240, + }) + ); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + expect(select).not.toBeNull(); + expect(host.textContent).not.toContain('Logs for'); + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + 'Lead', + 'Builder', + ]); + expect(select.value).toBe('team-lead'); + const memberSelect = host.querySelector('[data-testid="member-select"]'); + expect(memberSelect).not.toBeNull(); + expect(memberSelect?.getAttribute('data-search-placeholder')).toBe('Search log sources...'); + expect(select.getAttribute('data-trigger-aria-label')).toBe('Log source'); + expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); + expect(sectionState.memberLogStreamCalls).toEqual([]); + expect(sectionState.controllerCalls.at(-1)).toEqual({ + teamName: 'demo-team', + enabled: true, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the lead-only team UI simple without an unnecessary source selector', async () => { + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + expect(host.querySelector('select[aria-label="Log source"]')).toBeNull(); + expect(host.textContent).not.toContain('Logs for'); + expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); + expect(sectionState.controllerCalls.at(-1)).toEqual({ + teamName: 'demo-team', + enabled: true, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('reuses the member log stream section when a teammate is selected', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ClaudeLogsSection, { + teamName: 'demo-team', + sidebarViewerMaxHeight: 240, + }) + ); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull(); + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); + expect(sectionState.memberLogStreamCalls.at(-1)).toEqual({ + teamName: 'demo-team', + memberName: 'Builder', + enabled: true, + }); + expect(sectionState.controllerCalls.at(-1)).toEqual({ + teamName: 'demo-team', + enabled: false, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('switches back to lead logs from a selected teammate in the compact section', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); + + await act(async () => { + select.value = 'team-lead'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="member-log-stream"]')).toBeNull(); + expect(sectionState.controllerCalls.at(-1)).toEqual({ + teamName: 'demo-team', + enabled: true, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('switches directly between multiple teammate log sources', async () => { + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Builder', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Reviewer', + role: 'reviewer', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + 'Lead', + 'Builder', + 'Reviewer', + ]); + + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); + + await act(async () => { + select.value = 'Reviewer'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Reviewer'); + expect(sectionState.memberLogStreamCalls.at(-1)).toEqual({ + teamName: 'demo-team', + memberName: 'Reviewer', + enabled: true, + }); + expect(sectionState.controllerCalls.at(-1)).toEqual({ + teamName: 'demo-team', + enabled: false, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows the same legacy fallback as the member popup after a stream error', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const streamButton = host.querySelector('[data-testid="member-log-stream"]') as HTMLButtonElement; + await act(async () => { + streamButton.click(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Builder'); + expect(host.textContent).toContain('Legacy Logs Fallback'); + expect(host.querySelector('[data-testid="legacy-member-logs"]')?.textContent).toBe('Builder'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps removed teammates available for historical logs and labels them', async () => { + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Builder', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + removedAt: 1715000000000, + }, + ]; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + 'Lead', + 'Builder (removed)', + ]); + + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); + expect(host.textContent).toContain('Builder (removed)'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('deduplicates active and removed teammate sources by name and prefers the active member', async () => { + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Builder', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + removedAt: 1715000000000, + }, + { + name: 'Builder', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + 'Lead', + 'Builder', + ]); + + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const stream = host.querySelector('[data-testid="member-log-stream"]') as HTMLElement; + expect(stream.textContent).toBe('Builder'); + expect(stream.getAttribute('data-removed')).toBe('false'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the selected source stable when a teammate name only changes casing', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + let select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'builder', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + + await act(async () => { + root.render( + React.createElement(ClaudeLogsSection, { + teamName: 'demo-team', + sidebarViewerMaxHeight: 240, + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + expect(select.value).toBe('builder'); + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('builder'); + expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('resets to lead logs when the team changes even if teammate names overlap', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'team-a' })); + await Promise.resolve(); + }); + + let select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); + + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Builder', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'team-b' })); + await Promise.resolve(); + await Promise.resolve(); + }); + + select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + expect(select.value).toBe('team-lead'); + expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); + expect(sectionState.memberLogStreamCalls).not.toContainEqual({ + teamName: 'team-b', + memberName: 'Builder', + enabled: true, + }); + expect(sectionState.controllerCalls.at(-1)).toEqual({ + teamName: 'team-b', + enabled: true, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('falls back to legacy member logs when the stream UI gate is disabled', async () => { + sectionState.memberLogStreamUiEnabled = false; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ClaudeLogsSection, { + teamName: 'demo-team', + sidebarViewerMaxHeight: 240, + }) + ); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="member-log-stream"]')).toBeNull(); + expect(host.querySelector('[data-testid="legacy-member-logs"]')?.textContent).toBe('Builder'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('opens selected teammate logs in fullscreen without switching back to lead', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const fullscreenButton = host.querySelector( + 'button[aria-label="Open fullscreen logs"]' + ) as HTMLButtonElement; + await act(async () => { + fullscreenButton.click(); + await Promise.resolve(); + }); + + const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement; + expect(dialog.textContent).toContain('Logs'); + expect(dialog.textContent).not.toContain('Logs for'); + expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe( + 'Builder' + ); + expect(dialog.textContent).toContain('Builder'); + expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('can switch log sources from the fullscreen dialog', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const fullscreenButton = host.querySelector( + 'button[aria-label="Open fullscreen logs"]' + ) as HTMLButtonElement; + await act(async () => { + fullscreenButton.click(); + await Promise.resolve(); + }); + + const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement; + expect(dialog.textContent).toContain('Logs'); + expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe( + 'team-lead' + ); + expect( + dialog.querySelector('[data-testid="lead-logs-panel"] [data-testid="member-select"]') + ).not.toBeNull(); + + const dialogSelect = dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + dialogSelect.value = 'Builder'; + dialogSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); + expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull(); + expect(sectionState.controllerCalls.at(-1)).toEqual({ + teamName: 'demo-team', + enabled: false, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('switches back to lead logs from teammate logs in the fullscreen dialog', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const fullscreenButton = host.querySelector( + 'button[aria-label="Open fullscreen logs"]' + ) as HTMLButtonElement; + await act(async () => { + fullscreenButton.click(); + await Promise.resolve(); + }); + + const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement; + const dialogSelect = dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + expect(dialogSelect.value).toBe('Builder'); + + await act(async () => { + dialogSelect.value = 'team-lead'; + dialogSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(dialog.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); + expect(dialog.querySelector('[data-testid="member-log-stream"]')).toBeNull(); + expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe( + 'team-lead' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('returns to lead logs when the selected teammate disappears from the roster', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Reviewer', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + + await act(async () => { + root.render( + React.createElement(ClaudeLogsSection, { + teamName: 'demo-team', + sidebarViewerMaxHeight: 240, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); + expect((host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe( + 'team-lead' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps fullscreen open and falls back to lead logs when the selected teammate disappears', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' })); + await Promise.resolve(); + }); + + const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; + await act(async () => { + select.value = 'Builder'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const fullscreenButton = host.querySelector( + 'button[aria-label="Open fullscreen logs"]' + ) as HTMLButtonElement; + await act(async () => { + fullscreenButton.click(); + await Promise.resolve(); + }); + + sectionState.members = [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'Reviewer', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ]; + + await act(async () => { + root.render( + React.createElement(ClaudeLogsSection, { + teamName: 'demo-team', + sidebarViewerMaxHeight: 240, + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement; + expect(dialog).not.toBeNull(); + expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe( + 'team-lead' + ); + expect(dialog.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); + expect(dialog.querySelector('[data-testid="member-log-stream"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts index 2b8dc1ca..2cbeddaa 100644 --- a/test/renderer/components/team/members/MemberDetailDialog.test.ts +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -1,8 +1,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useStore } from '@renderer/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; diff --git a/test/renderer/components/team/teamLogSources.test.ts b/test/renderer/components/team/teamLogSources.test.ts new file mode 100644 index 00000000..eb57f4ae --- /dev/null +++ b/test/renderer/components/team/teamLogSources.test.ts @@ -0,0 +1,81 @@ +import { + buildSelectableLogMembers, + formatMemberLogSourceDescription, + formatMemberLogSourceLabel, + getMemberNameFromLogSourceKey, + memberLogSourceKey, + resolveLeadLogMember, +} from '@renderer/components/team/teamLogSources'; +import { describe, expect, it } from 'vitest'; + +import type { ResolvedTeamMember } from '@shared/types'; + +function member( + name: string, + overrides: Partial = {} +): ResolvedTeamMember { + return { + name, + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + ...overrides, + }; +} + +describe('team log source helpers', () => { + it('builds teammate sources without lead, user, blank names, or duplicate removed entries', () => { + const sources = buildSelectableLogMembers([ + member('team-lead', { agentType: 'team-lead' }), + member('user'), + member(' '), + member('Builder', { removedAt: 1715000000000 }), + member('Reviewer'), + member('builder'), + ]); + + expect(sources.map((source) => source.name)).toEqual(['builder', 'Reviewer']); + expect(sources[0]?.removedAt).toBeUndefined(); + }); + + it('keeps first active duplicate source and preserves original ordering slot', () => { + const sources = buildSelectableLogMembers([ + member('Zed'), + member('alpha', { removedAt: 1715000000000 }), + member('Beta'), + member('ALPHA'), + member('alpha-late'), + ]); + + expect(sources.map((source) => source.name)).toEqual(['Zed', 'ALPHA', 'Beta', 'alpha-late']); + }); + + it('resolves active lead before removed lead and falls back safely when roster has no lead', () => { + expect( + resolveLeadLogMember([ + member('team-lead', { agentType: 'team-lead', removedAt: 1715000000000 }), + member('captain', { agentType: 'orchestrator' }), + ]).name + ).toBe('captain'); + + const fallback = resolveLeadLogMember([member('Builder')]); + expect(fallback.name).toBe('team-lead'); + expect(fallback.agentType).toBe('team-lead'); + }); + + it('formats source labels, descriptions, and stable member source keys', () => { + const removed = member('Builder', { removedAt: 1715000000000 }); + const developer = member('Reviewer', { role: 'reviewer' }); + const lead = member('lead-alias', { agentType: 'lead' }); + + expect(formatMemberLogSourceLabel(removed)).toBe('Builder (removed)'); + expect(formatMemberLogSourceDescription(removed)).toBe('Removed'); + expect(formatMemberLogSourceDescription(developer)).toBe('Reviewer'); + expect(formatMemberLogSourceDescription(lead)).toBe('Team Lead'); + expect(getMemberNameFromLogSourceKey(memberLogSourceKey('name:with:colon'))).toBe( + 'name:with:colon' + ); + }); +}); diff --git a/test/renderer/components/team/useClaudeLogsController.test.tsx b/test/renderer/components/team/useClaudeLogsController.test.tsx new file mode 100644 index 00000000..e70c8877 --- /dev/null +++ b/test/renderer/components/team/useClaudeLogsController.test.tsx @@ -0,0 +1,317 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ClaudeLogsFilterState } from '@renderer/components/team/claudeLogsFilterState'; +import type { ClaudeLogsViewerState } from '@renderer/components/team/CliLogsRichView'; +import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController'; +import type { TeamClaudeLogsResponse } from '@shared/types'; + +const controllerState = vi.hoisted(() => ({ + getClaudeLogs: vi.fn<() => Promise>(), + setSidebarState: vi.fn(), +})); + +function createLogsResponse(text = 'lead'): TeamClaudeLogsResponse { + return { + lines: [`{"type":"assistant","content":[{"type":"text","text":"${text}"}]}`], + total: 1, + hasMore: false, + }; +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getClaudeLogs: controllerState.getClaudeLogs, + }, + }, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: unknown) => unknown) => + selector({ + selectedTeamName: 'demo-team', + selectedTeamData: { isAlive: true }, + }), +})); + +vi.mock('@renderer/components/team/sidebar/teamSidebarUiState', () => ({ + getTeamClaudeLogsSidebarUiState: () => ({ + searchQuery: '', + filter: { + streams: new Set(['stdout', 'stderr']), + kinds: new Set(['output', 'thinking', 'tool']), + } satisfies ClaudeLogsFilterState, + filterOpen: false, + viewerState: {} as ClaudeLogsViewerState, + }), + setTeamClaudeLogsSidebarUiState: controllerState.setSidebarState, +})); + +import { useClaudeLogsController } from '@renderer/components/team/useClaudeLogsController'; + +function ControllerHarness({ enabled }: Readonly<{ enabled: boolean }>): React.JSX.Element { + useClaudeLogsController('demo-team', { enabled }); + return React.createElement('div'); +} + +function ControllerCaptureHarness({ + enabled, + onController, +}: Readonly<{ + enabled: boolean; + onController: (controller: ClaudeLogsController) => void; +}>): React.JSX.Element { + const controller = useClaudeLogsController('demo-team', { enabled }); + onController(controller); + return React.createElement('div'); +} + +describe('useClaudeLogsController enabled option', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + controllerState.getClaudeLogs.mockResolvedValue(createLogsResponse()); + controllerState.setSidebarState.mockClear(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('does not fetch lead logs while disabled and loads them when re-enabled', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: false })); + await Promise.resolve(); + }); + + expect(controllerState.getClaudeLogs).not.toHaveBeenCalled(); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + expect(controllerState.getClaudeLogs).toHaveBeenCalledWith('demo-team', { + offset: 0, + limit: 100, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('queues a fresh lead fetch when re-enabled before the previous request settles', async () => { + const firstRequest = createDeferred(); + controllerState.getClaudeLogs + .mockReturnValueOnce(firstRequest.promise) + .mockResolvedValue(createLogsResponse('fresh lead')); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: true })); + await Promise.resolve(); + }); + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: false })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: true })); + await Promise.resolve(); + }); + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + firstRequest.resolve(createLogsResponse('stale lead')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(2); + expect(controllerState.getClaudeLogs).toHaveBeenLastCalledWith('demo-team', { + offset: 0, + limit: 100, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('queues interval-driven polls when the current request is still in flight', async () => { + vi.useFakeTimers(); + const firstRequest = createDeferred(); + controllerState.getClaudeLogs + .mockReturnValueOnce(firstRequest.promise) + .mockResolvedValue(createLogsResponse('interval fresh lead')); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: true })); + await Promise.resolve(); + }); + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(2000); + await Promise.resolve(); + }); + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + firstRequest.resolve(createLogsResponse('stale lead')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(2); + expect(controllerState.getClaudeLogs).toHaveBeenLastCalledWith('demo-team', { + offset: 0, + limit: 100, + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not run a queued lead fetch after being disabled again', async () => { + const firstRequest = createDeferred(); + controllerState.getClaudeLogs + .mockReturnValueOnce(firstRequest.promise) + .mockResolvedValue(createLogsResponse('unexpected lead')); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: true })); + await Promise.resolve(); + }); + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: false })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: true })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(ControllerHarness, { enabled: false })); + await Promise.resolve(); + }); + + await act(async () => { + firstRequest.resolve(createLogsResponse('stale lead')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not load more or apply pending lead logs while disabled', async () => { + let latestController: ClaudeLogsController | null = null; + const getLatestController = (): ClaudeLogsController => { + if (!latestController) { + throw new Error('Controller was not captured'); + } + return latestController; + }; + controllerState.getClaudeLogs.mockResolvedValue({ + ...createLogsResponse('lead with more'), + hasMore: true, + total: 150, + }); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ControllerCaptureHarness, { + enabled: true, + onController: (controller) => { + latestController = controller; + }, + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + expect(getLatestController().data.hasMore).toBe(true); + + await act(async () => { + root.render( + React.createElement(ControllerCaptureHarness, { + enabled: false, + onController: (controller) => { + latestController = controller; + }, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + const disabledController = getLatestController(); + await disabledController.loadOlderLogs(); + await disabledController.applyPending(); + await Promise.resolve(); + }); + + expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/ui/MemberSelect.test.tsx b/test/renderer/components/ui/MemberSelect.test.tsx new file mode 100644 index 00000000..248e2e1b --- /dev/null +++ b/test/renderer/components/ui/MemberSelect.test.tsx @@ -0,0 +1,170 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ResolvedTeamMember } from '@shared/types'; + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ + theme: 'dark', + resolvedTheme: 'dark', + isDark: true, + isLight: false, + }), +})); + +function member( + name: string, + overrides: Partial = {} +): ResolvedTeamMember { + return { + name, + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + ...overrides, + }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('MemberSelect', () => { + let host: HTMLDivElement; + let root: ReturnType; + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = vi.fn(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + await flush(); + }); + document.body.innerHTML = ''; + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('preserves Create Task defaults for unassigned and lead display', async () => { + const onChange = vi.fn(); + + await act(async () => { + root.render( + + ); + await flush(); + }); + + const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement; + expect(trigger.textContent).toContain('Unassigned'); + expect(trigger.getAttribute('aria-label')).toBeNull(); + + await act(async () => { + trigger.click(); + await flush(); + }); + + const list = document.body.querySelector('[cmdk-list]') as HTMLElement | null; + expect(list?.textContent).toContain('Unassigned'); + expect(list?.textContent).toContain('lead'); + expect(list?.textContent).toContain('alice'); + expect(document.body.querySelector('input')?.getAttribute('placeholder')).toBe( + 'Search members...' + ); + }); + + it('supports custom log-source labels, descriptions, search text, and selection', async () => { + const onChange = vi.fn(); + + await act(async () => { + root.render( + { + if (candidate.name === 'team-lead') return 'Lead'; + if (candidate.removedAt) return `${candidate.name} (removed)`; + return candidate.name; + }} + getMemberDescription={(candidate) => { + if (candidate.name === 'team-lead') return 'Team Lead'; + if (candidate.removedAt) return 'Removed'; + return 'Reviewer'; + }} + /> + ); + await flush(); + }); + + const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement; + expect(trigger.textContent).toContain('Lead'); + expect(trigger.getAttribute('aria-label')).toBe('Log source'); + + await act(async () => { + trigger.click(); + await flush(); + }); + + const input = document.body.querySelector('input') as HTMLInputElement; + const list = document.body.querySelector('[cmdk-list]') as HTMLElement | null; + expect(input.getAttribute('placeholder')).toBe('Search log sources...'); + expect(list?.textContent).toContain('Lead'); + expect(list?.textContent).toContain('Team Lead'); + expect(list?.textContent).toContain('Builder (removed)'); + expect(list?.textContent).toContain('Removed'); + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + valueSetter?.call(input, 'removed'); + input.dispatchEvent(new Event('input', { bubbles: true })); + await flush(); + }); + + expect(list?.textContent).toContain('Builder (removed)'); + expect(list?.textContent).not.toContain('Reviewer'); + expect(list?.textContent).not.toContain('Team Lead'); + + const builderItem = Array.from(list?.querySelectorAll('[cmdk-item]') ?? []).find((item) => + item.textContent?.includes('Builder (removed)') + ) as HTMLElement | undefined; + expect(builderItem).toBeDefined(); + + await act(async () => { + builderItem?.click(); + await flush(); + }); + + expect(onChange).toHaveBeenCalledWith('Builder'); + }); +});