From 3a7f9ea10bde9d45a060a3ac9bf117e7a39dec01 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 24 May 2026 15:40:31 +0300 Subject: [PATCH 1/3] feat: add team log member filtering --- .../components/layout/TeamTabSectionNav.tsx | 2 +- .../components/team/ClaudeLogsDialog.tsx | 71 -- .../team/ClaudeLogsFilterPopover.tsx | 20 +- .../components/team/ClaudeLogsPanel.tsx | 7 +- .../components/team/ClaudeLogsSection.tsx | 306 ++++- .../components/team/claudeLogsFilterState.ts | 12 + .../team/members/MemberDetailDialog.tsx | 34 +- .../MemberLogStreamWithLegacyFallback.tsx | 52 + .../team/sidebar/teamSidebarUiState.ts | 3 +- .../components/team/teamLogSources.ts | 74 ++ .../team/useClaudeLogsController.ts | 48 +- src/renderer/components/ui/MemberSelect.tsx | 43 +- .../layout/TeamTabSectionNav.test.tsx | 46 + .../components/team/ClaudeLogsPanel.test.ts | 44 + .../components/team/ClaudeLogsSection.test.ts | 1036 +++++++++++++++++ .../team/members/MemberDetailDialog.test.ts | 2 +- .../components/team/teamLogSources.test.ts | 81 ++ .../team/useClaudeLogsController.test.tsx | 276 +++++ .../components/ui/MemberSelect.test.tsx | 174 +++ 19 files changed, 2185 insertions(+), 146 deletions(-) delete mode 100644 src/renderer/components/team/ClaudeLogsDialog.tsx create mode 100644 src/renderer/components/team/claudeLogsFilterState.ts create mode 100644 src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx create mode 100644 src/renderer/components/team/teamLogSources.ts create mode 100644 test/renderer/components/layout/TeamTabSectionNav.test.tsx create mode 100644 test/renderer/components/team/ClaudeLogsSection.test.ts create mode 100644 test/renderer/components/team/teamLogSources.test.ts create mode 100644 test/renderer/components/team/useClaudeLogsController.test.tsx create mode 100644 test/renderer/components/ui/MemberSelect.test.tsx diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index 4cd48f64..8ae5e1ec 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -15,7 +15,7 @@ const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [ { id: 'team', label: 'Team', icon: Users }, { id: 'sessions', label: 'Sessions', icon: History }, { id: 'kanban', label: 'Kanban', icon: Columns3 }, - { id: 'claude-logs', label: 'Claude Logs', icon: Terminal }, + { id: 'claude-logs', label: 'Logs', icon: Terminal }, { id: 'messages', label: 'Messages', icon: MessageSquare }, ]; 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 aeeb70e6..80fd6f13 100644 --- a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx +++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx @@ -6,18 +6,12 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; 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; @@ -108,7 +102,7 @@ export const ClaudeLogsFilterPopover = ({ variant="ghost" size="sm" className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]" - aria-label="Filter Claude logs" + aria-label="Filter logs" > {activeCount > 0 && ( diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx index 05b5784c..9fef9377 100644 --- a/src/renderer/components/team/ClaudeLogsPanel.tsx +++ b/src/renderer/components/team/ClaudeLogsPanel.tsx @@ -29,6 +29,7 @@ interface ClaudeLogsPanelProps { /** Extra className for the panel wrapper. */ className?: string; compactMetaInTooltip?: boolean; + toolbarControlsStart?: React.ReactNode; } // ============================================================================= @@ -41,6 +42,7 @@ export const ClaudeLogsPanel = ({ viewerMaxHeight, className, compactMetaInTooltip = false, + toolbarControlsStart, }: ClaudeLogsPanelProps): React.JSX.Element => { const { data, @@ -86,7 +88,10 @@ export const ClaudeLogsPanel = ({ 'Team is not running.' )} -
+
+ {toolbarControlsStart ? ( +
{toolbarControlsStart}
+ ) : null} {data.total > 0 ? ( <>
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 818e68fb..cfadb871 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -1,16 +1,33 @@ -import { memo, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; 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 @@ -78,6 +95,177 @@ 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 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="Select log source..." + searchPlaceholder="Search log sources..." + emptyMessage="No log sources found." + ariaLabel="Log source" + getMemberLabel={(member) => + isLeadMember(member) ? 'Lead' : 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 sourceSelector = + members.length > 0 ? ( + + ) : null; + + return ( + + + + + + + + Logs + {showingLeadLogs && ctrl.badge != null ? ( + + ({ctrl.badge}) + + ) : null} + {showingLeadLogs && ctrl.online ? ( + + + + + ) : null} + + + +
+ {showingLeadLogs ? ( + + ) : selectedMember ? ( + <> + {sourceSelector ? ( +
{sourceSelector}
+ ) : null} + + + ) : ( +
+ Select a log source. +
+ )} +
+
+
+ ); +}; + // ============================================================================= // Main component // ============================================================================= @@ -88,22 +276,79 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({ sidebarViewerMaxHeight, onOpenChange, }: ClaudeLogsSectionProps): React.JSX.Element { - 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 ? ( @@ -114,9 +359,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 ? ( <> @@ -124,7 +378,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({ - ) : ctrl.data.total > 0 ? ( + ) : canOpenFullscreen ? (
@@ -135,7 +159,7 @@ export const MemberSelect = ({ onWheel={(e) => e.stopPropagation()} > - No members found. + {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) ); @@ -167,7 +195,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..e0c34a99 --- /dev/null +++ b/test/renderer/components/team/useClaudeLogsController.test.tsx @@ -0,0 +1,276 @@ +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.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('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..50bb4b63 --- /dev/null +++ b/test/renderer/components/ui/MemberSelect.test.tsx @@ -0,0 +1,174 @@ +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( + + candidate.name === 'team-lead' + ? 'Lead' + : candidate.removedAt + ? `${candidate.name} (removed)` + : candidate.name + } + getMemberDescription={(candidate) => + candidate.name === 'team-lead' + ? 'Team Lead' + : candidate.removedAt + ? 'Removed' + : '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'); + }); +}); From 5abd096c61567c937783a69df347654de6db08fc Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 24 May 2026 16:05:57 +0300 Subject: [PATCH 2/3] fix: address log filter review feedback --- .../team/useClaudeLogsController.ts | 2 +- src/renderer/components/ui/MemberSelect.tsx | 4 +- .../team/useClaudeLogsController.test.tsx | 41 +++++++++++++++++++ .../components/ui/MemberSelect.test.tsx | 24 +++++------ 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index 6237c700..25b2bc87 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -514,7 +514,7 @@ export function useClaudeLogsController( }; void fetchLogs({ queueIfBusy: true }); - const id = window.setInterval(() => void fetchLogs(), POLL_MS); + const id = window.setInterval(() => void fetchLogs({ queueIfBusy: true }), POLL_MS); return () => { cancelled = true; window.clearInterval(id); diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx index 1ece6e05..7c1d23a7 100644 --- a/src/renderer/components/ui/MemberSelect.tsx +++ b/src/renderer/components/ui/MemberSelect.tsx @@ -46,8 +46,8 @@ export const MemberSelect = ({ size = 'sm', disabled = false, className, - searchPlaceholder = 'Search members...', - emptyMessage = 'No members found.', + searchPlaceholder, + emptyMessage, getMemberLabel, getMemberDescription, ariaLabel, diff --git a/test/renderer/components/team/useClaudeLogsController.test.tsx b/test/renderer/components/team/useClaudeLogsController.test.tsx index e0c34a99..e70c8877 100644 --- a/test/renderer/components/team/useClaudeLogsController.test.tsx +++ b/test/renderer/components/team/useClaudeLogsController.test.tsx @@ -92,6 +92,7 @@ describe('useClaudeLogsController enabled option', () => { afterEach(() => { document.body.innerHTML = ''; + vi.useRealTimers(); vi.clearAllMocks(); vi.unstubAllGlobals(); }); @@ -170,6 +171,46 @@ describe('useClaudeLogsController enabled option', () => { }); }); + 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 diff --git a/test/renderer/components/ui/MemberSelect.test.tsx b/test/renderer/components/ui/MemberSelect.test.tsx index 50bb4b63..248e2e1b 100644 --- a/test/renderer/components/ui/MemberSelect.test.tsx +++ b/test/renderer/components/ui/MemberSelect.test.tsx @@ -112,20 +112,16 @@ describe('MemberSelect', () => { searchPlaceholder="Search log sources..." emptyMessage="No log sources found." ariaLabel="Log source" - getMemberLabel={(candidate) => - candidate.name === 'team-lead' - ? 'Lead' - : candidate.removedAt - ? `${candidate.name} (removed)` - : candidate.name - } - getMemberDescription={(candidate) => - candidate.name === 'team-lead' - ? 'Team Lead' - : candidate.removedAt - ? 'Removed' - : 'Reviewer' - } + getMemberLabel={(candidate) => { + 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(); From 6ebe375cf9147daf1971bf67404d6c8ff5afd9a6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 24 May 2026 16:43:16 +0300 Subject: [PATCH 3/3] fix: restore runtime status ci expectations --- .../renderer/locales/en/team.json | 4 ++ .../renderer/locales/ru/team.json | 4 ++ .../localization/renderer/resources.d.ts | 4 ++ .../runtime/providerConnectionUi.ts | 43 ++++++++++++++++--- .../ProvisioningProviderStatusList.tsx | 12 +----- 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json index cc284f8d..bb5fd673 100644 --- a/src/features/localization/renderer/locales/en/team.json +++ b/src/features/localization/renderer/locales/en/team.json @@ -2062,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/team.json b/src/features/localization/renderer/locales/ru/team.json index 159379e7..4eb9fd8e 100644 --- a/src/features/localization/renderer/locales/ru/team.json +++ b/src/features/localization/renderer/locales/ru/team.json @@ -2062,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 96aec47d..884e9862 100644 --- a/src/features/localization/renderer/resources.d.ts +++ b/src/features/localization/renderer/resources.d.ts @@ -4503,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/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index fe6957e7..5163256f 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -895,16 +895,6 @@ function getProvisioningProviderSettingsActionLabel( : null; } -function getDisplayDetailText( - detail: string, - status: ProvisioningProviderCheckStatus, - providerId: TeamProviderId, - t: TeamTranslator -): string { - const summary = summarizeDetail(detail, status, providerId); - return summary ? localizeProvisioningDetailSummary(summary, t) : detail; -} - function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null { if (check.providerId !== 'opencode') { return null; @@ -990,7 +980,7 @@ export const ProvisioningProviderStatusList = ({ check.providerId )}`} > - {getDisplayDetailText(detail, check.status, check.providerId, t)} + {detail}

))}