feat: add team log member filtering Merge pull request #151 from 777genius/feat/team-log-member-filter
feat: add team log member filtering
This commit is contained in:
commit
049bc2ce7c
25 changed files with 2311 additions and 172 deletions
|
|
@ -833,7 +833,7 @@
|
|||
"team": "Team",
|
||||
"sessions": "Sessions",
|
||||
"kanban": "Kanban",
|
||||
"claudeLogs": "Claude Logs",
|
||||
"claudeLogs": "Logs",
|
||||
"messages": "Messages"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -833,7 +833,7 @@
|
|||
"team": "Команда",
|
||||
"sessions": "Сессии",
|
||||
"kanban": "Канбан",
|
||||
"claudeLogs": "Логи Claude",
|
||||
"claudeLogs": "Логи",
|
||||
"messages": "Сообщения"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}}';
|
||||
|
|
|
|||
|
|
@ -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, unknown>
|
||||
): 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, unknown>
|
||||
): string {
|
||||
const interpolatedFallback = interpolateProviderConnectionFallback(fallback, options);
|
||||
if (!t) {
|
||||
return fallback;
|
||||
return interpolatedFallback;
|
||||
}
|
||||
|
||||
return (t as (translationKey: string, options?: Record<string, unknown>) => string)(key, {
|
||||
defaultValue: fallback,
|
||||
...options,
|
||||
});
|
||||
const translated = (t as (translationKey: string, options?: Record<string, unknown>) => string)(
|
||||
key,
|
||||
{
|
||||
...options,
|
||||
defaultValue: fallback,
|
||||
}
|
||||
);
|
||||
|
||||
return interpolateProviderConnectionFallback(translated, options);
|
||||
}
|
||||
|
||||
const CODEX_NATIVE_LABEL = 'Codex native';
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[90vh] w-[80vw] max-w-none flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
|
||||
<Terminal size={12} />
|
||||
</span>
|
||||
Claude logs
|
||||
{ctrl.badge != null && (
|
||||
<span className="font-mono text-[11px] text-[var(--color-text-muted)]">
|
||||
({ctrl.badge})
|
||||
</span>
|
||||
)}
|
||||
{ctrl.online && (
|
||||
<span className="pointer-events-none relative inline-flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ClaudeLogsPanel
|
||||
ctrl={ctrl}
|
||||
viewerClassName="max-h-full h-full"
|
||||
className="flex h-full flex-col [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<ClaudeLogStream>;
|
||||
kinds: Set<ClaudeLogKind>;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
|
||||
streams: new Set<ClaudeLogStream>(['stdout', 'stderr']),
|
||||
kinds: new Set<ClaudeLogKind>(['output', 'thinking', 'tool']),
|
||||
};
|
||||
import {
|
||||
type ClaudeLogKind,
|
||||
type ClaudeLogsFilterState,
|
||||
type ClaudeLogStream,
|
||||
DEFAULT_CLAUDE_LOGS_FILTER,
|
||||
} from './claudeLogsFilterState';
|
||||
|
||||
function setEquals<T>(a: Set<T>, b: Set<T>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center justify-end gap-2">
|
||||
{toolbarControlsStart ? (
|
||||
<div className="min-w-0 shrink-0">{toolbarControlsStart}</div>
|
||||
) : null}
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={cn('min-w-0 pb-2', className)}>
|
||||
<MemberSelect
|
||||
members={sourceMembers}
|
||||
value={selectedMemberName}
|
||||
onChange={(memberName) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberSourcePill = ({ member }: { member: ResolvedTeamMember }): React.JSX.Element => (
|
||||
<span
|
||||
className="min-w-0 truncate rounded-md border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
||||
title={formatMemberLogSourceLabel(member)}
|
||||
>
|
||||
{formatMemberLogSourceLabel(member)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const MemberLogsSourcePanel = ({
|
||||
teamName,
|
||||
member,
|
||||
enabled,
|
||||
maxHeight,
|
||||
}: {
|
||||
teamName: string;
|
||||
member: ResolvedTeamMember;
|
||||
enabled: boolean;
|
||||
maxHeight?: number;
|
||||
}): React.JSX.Element => {
|
||||
const content = (
|
||||
<MemberLogStreamWithLegacyFallback teamName={teamName} member={member} enabled={enabled} />
|
||||
);
|
||||
|
||||
if (maxHeight === undefined) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 overflow-auto pr-1" style={{ maxHeight }}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<typeof useClaudeLogsController>;
|
||||
selectedMember: ResolvedTeamMember | null;
|
||||
}): React.JSX.Element => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const sourceSelector =
|
||||
members.length > 0 ? (
|
||||
<TeamLogsSourceSelector
|
||||
leadMember={leadMember}
|
||||
members={members}
|
||||
selectedKey={selectedKey}
|
||||
onChange={onSourceChange}
|
||||
className="w-64 max-w-[min(18rem,40vw)] shrink-0 pb-0"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[90vh] w-[80vw] max-w-none flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
|
||||
<Terminal size={12} />
|
||||
</span>
|
||||
{t('claudeLogs.logsTitle')}
|
||||
{showingLeadLogs && ctrl.badge != null ? (
|
||||
<span className="font-mono text-[11px] text-[var(--color-text-muted)]">
|
||||
({ctrl.badge})
|
||||
</span>
|
||||
) : null}
|
||||
{showingLeadLogs && ctrl.online ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className={cn('min-h-0 flex-1', showingLeadLogs ? 'overflow-hidden' : 'overflow-auto')}
|
||||
>
|
||||
{showingLeadLogs ? (
|
||||
<ClaudeLogsPanel
|
||||
ctrl={ctrl}
|
||||
viewerClassName="max-h-full h-full"
|
||||
className="flex h-full flex-col [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||
toolbarControlsStart={sourceSelector}
|
||||
/>
|
||||
) : selectedMember ? (
|
||||
<>
|
||||
{sourceSelector ? (
|
||||
<div className="mb-3 flex justify-end">{sourceSelector}</div>
|
||||
) : null}
|
||||
<MemberLogsSourcePanel
|
||||
teamName={teamName}
|
||||
member={selectedMember}
|
||||
enabled={open && selectedKey === memberLogSourceKey(selectedMember.name)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-[var(--color-text-muted)]">
|
||||
{t('claudeLogs.sourceSelect.selectSourceEmpty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// 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(
|
||||
() => (
|
||||
<span className={cn('flex min-w-0 items-center gap-2', isSidebar && 'basis-full pt-0.5')}>
|
||||
{ctrl.online ? (
|
||||
{showingLeadLogs && ctrl.online ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
{ctrl.lastLogPreview ? <LogPreviewInline preview={ctrl.lastLogPreview} /> : null}
|
||||
{showingLeadLogs && ctrl.lastLogPreview ? (
|
||||
<LogPreviewInline preview={ctrl.lastLogPreview} />
|
||||
) : null}
|
||||
{!showingLeadLogs && selectedMember ? <MemberSourcePill member={selectedMember} /> : null}
|
||||
{showHeaderSkeleton ? (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 opacity-70">
|
||||
<LogsHeaderSkeletonPill className="size-3 rounded" />
|
||||
|
|
@ -116,9 +365,18 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
) : null}
|
||||
</span>
|
||||
),
|
||||
[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 ? (
|
||||
<>
|
||||
<LogsHeaderSkeletonPill className="h-5 w-14" />
|
||||
|
|
@ -126,7 +384,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
<Expand size={14} />
|
||||
</span>
|
||||
</>
|
||||
) : ctrl.data.total > 0 ? (
|
||||
) : canOpenFullscreen ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -152,7 +410,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
sectionId="claude-logs"
|
||||
title={t('claudeLogs.logsTitle')}
|
||||
icon={null}
|
||||
badge={ctrl.badge}
|
||||
badge={showingLeadLogs ? ctrl.badge : undefined}
|
||||
afterBadge={afterBadge}
|
||||
headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined}
|
||||
headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined}
|
||||
|
|
@ -164,22 +422,50 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
contentClassName="pt-0 [overflow-anchor:none]"
|
||||
>
|
||||
{/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */}
|
||||
<TeamLogsSourceSelector
|
||||
leadMember={leadMember}
|
||||
members={selectableMembers}
|
||||
selectedKey={effectiveSelectedSourceKey}
|
||||
onChange={setSelectedSourceKey}
|
||||
/>
|
||||
{dialogOpen ? (
|
||||
<div className="flex items-center gap-2 p-2 text-xs text-[var(--color-text-muted)]">
|
||||
<Expand size={12} />
|
||||
{t('claudeLogs.viewingFullscreen')}
|
||||
</div>
|
||||
) : (
|
||||
) : showingLeadLogs ? (
|
||||
<ClaudeLogsPanel
|
||||
ctrl={ctrl}
|
||||
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
|
||||
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
||||
compactMetaInTooltip={isSidebar}
|
||||
/>
|
||||
) : selectedMember ? (
|
||||
<MemberLogsSourcePanel
|
||||
teamName={teamName}
|
||||
member={selectedMember}
|
||||
enabled={effectiveSelectedSourceKey === memberLogSourceKey(selectedMember.name)}
|
||||
maxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-[var(--color-text-muted)]">
|
||||
{t('claudeLogs.sourceSelect.selectSourceEmpty')}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<ClaudeLogsDialog open={dialogOpen} onOpenChange={setDialogOpen} ctrl={ctrl} />
|
||||
<TeamLogsDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
teamName={teamName}
|
||||
leadMember={leadMember}
|
||||
members={selectableMembers}
|
||||
selectedKey={effectiveSelectedSourceKey}
|
||||
onSourceChange={setSelectedSourceKey}
|
||||
showingLeadLogs={showingLeadLogs}
|
||||
ctrl={ctrl}
|
||||
selectedMember={selectedMember}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
12
src/renderer/components/team/claudeLogsFilterState.ts
Normal file
12
src/renderer/components/team/claudeLogsFilterState.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export type ClaudeLogStream = 'stdout' | 'stderr';
|
||||
export type ClaudeLogKind = 'output' | 'thinking' | 'tool';
|
||||
|
||||
export interface ClaudeLogsFilterState {
|
||||
streams: Set<ClaudeLogStream>;
|
||||
kinds: Set<ClaudeLogKind>;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
|
||||
streams: new Set<ClaudeLogStream>(['stdout', 'stderr']),
|
||||
kinds: new Set<ClaudeLogKind>(['output', 'thinking', 'tool']),
|
||||
};
|
||||
|
|
@ -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}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<MemberDetailTab>(initialTab);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(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 = ({
|
|||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs" className="min-w-0 overflow-hidden">
|
||||
{memberLogStreamEnabled ? (
|
||||
<div className="space-y-4">
|
||||
<MemberLogStreamSection
|
||||
teamName={teamName}
|
||||
member={member}
|
||||
enabled={open && activeTab === 'logs'}
|
||||
onInitialLoadErrorChange={setShowLegacyLogsFallback}
|
||||
/>
|
||||
{showLegacyLogsFallback ? (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3">
|
||||
<div className="mb-3 text-xs font-semibold uppercase text-[var(--color-text-muted)]">
|
||||
{t('members.detail.legacyLogsFallback')}
|
||||
</div>
|
||||
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
||||
)}
|
||||
<MemberLogStreamWithLegacyFallback
|
||||
teamName={teamName}
|
||||
member={member}
|
||||
enabled={open && activeTab === 'logs'}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MemberLogStreamWithLegacyFallbackProps>): 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 <MemberLogsTab teamName={teamName} memberName={member.name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MemberLogStreamSection
|
||||
teamName={teamName}
|
||||
member={member}
|
||||
enabled={enabled}
|
||||
onInitialLoadErrorChange={setShowLegacyLogsFallback}
|
||||
/>
|
||||
{showLegacyLogsFallback ? (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3">
|
||||
<div className="mb-3 text-xs font-semibold uppercase text-[var(--color-text-muted)]">
|
||||
{t('members.detail.legacyLogsFallback')}
|
||||
</div>
|
||||
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
74
src/renderer/components/team/teamLogSources.ts
Normal file
74
src/renderer/components/team/teamLogSources.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const committedRef = useRef<TeamClaudeLogsResponse>({ 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<void> => {
|
||||
if (inFlightRef.current) return;
|
||||
const fetchLogs = async (options: { queueIfBusy?: boolean } = {}): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5">
|
||||
<img
|
||||
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, avatarSize)}
|
||||
alt=""
|
||||
|
|
@ -73,14 +96,14 @@ export const MemberSelect = ({
|
|||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 ${textSize} font-medium tracking-wide`}
|
||||
className={`min-w-0 truncate rounded px-1.5 py-0.5 ${textSize} font-medium tracking-wide`}
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
>
|
||||
{member.name === 'team-lead' ? 'lead' : member.name}
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
|
@ -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 = ({
|
|||
<CommandPrimitive.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={t('members.searchPlaceholder')}
|
||||
placeholder={searchPlaceholder ?? t('members.searchPlaceholder')}
|
||||
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -139,7 +163,7 @@ export const MemberSelect = ({
|
|||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
|
||||
{t('members.emptyMessage')}
|
||||
{emptyMessage ?? t('members.emptyMessage')}
|
||||
</CommandPrimitive.Empty>
|
||||
{allowUnassigned && !search.trim() ? (
|
||||
<CommandPrimitive.Item
|
||||
|
|
@ -161,8 +185,12 @@ export const MemberSelect = ({
|
|||
.filter((m) => {
|
||||
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 (
|
||||
<CommandPrimitive.Item
|
||||
|
|
@ -191,7 +220,7 @@ export const MemberSelect = ({
|
|||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 truncate font-medium" style={{ color: colors.text }}>
|
||||
{m.name === 'team-lead' ? 'lead' : m.name}
|
||||
{label}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
|
|
|
|||
46
test/renderer/components/layout/TeamTabSectionNav.test.tsx
Normal file
46
test/renderer/components/layout/TeamTabSectionNav.test.tsx
Normal file
|
|
@ -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<typeof createRoot>;
|
||||
|
||||
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(<TeamTabSectionNav teamName="demo-team" />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
1036
test/renderer/components/team/ClaudeLogsSection.test.ts
Normal file
1036
test/renderer/components/team/ClaudeLogsSection.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
81
test/renderer/components/team/teamLogSources.test.ts
Normal file
81
test/renderer/components/team/teamLogSources.test.ts
Normal file
|
|
@ -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> = {}
|
||||
): 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
317
test/renderer/components/team/useClaudeLogsController.test.tsx
Normal file
317
test/renderer/components/team/useClaudeLogsController.test.tsx
Normal file
|
|
@ -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<TeamClaudeLogsResponse>>(),
|
||||
setSidebarState: vi.fn(),
|
||||
}));
|
||||
|
||||
function createLogsResponse(text = 'lead'): TeamClaudeLogsResponse {
|
||||
return {
|
||||
lines: [`{"type":"assistant","content":[{"type":"text","text":"${text}"}]}`],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((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<TeamClaudeLogsResponse>();
|
||||
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<TeamClaudeLogsResponse>();
|
||||
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<TeamClaudeLogsResponse>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
170
test/renderer/components/ui/MemberSelect.test.tsx
Normal file
170
test/renderer/components/ui/MemberSelect.test.tsx
Normal file
|
|
@ -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> = {}
|
||||
): ResolvedTeamMember {
|
||||
return {
|
||||
name,
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('MemberSelect', () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
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(
|
||||
<MemberSelect
|
||||
members={[
|
||||
member('team-lead', { agentType: 'team-lead' }),
|
||||
member('alice', { role: 'reviewer' }),
|
||||
]}
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
allowUnassigned
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<MemberSelect
|
||||
members={[
|
||||
member('team-lead', { agentType: 'team-lead' }),
|
||||
member('Builder', { removedAt: 1715000000000 }),
|
||||
member('Reviewer', { role: 'reviewer' }),
|
||||
]}
|
||||
value="team-lead"
|
||||
onChange={onChange}
|
||||
searchPlaceholder="Search log sources..."
|
||||
emptyMessage="No log sources found."
|
||||
ariaLabel="Log source"
|
||||
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();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue