3662 lines
134 KiB
TypeScript
3662 lines
134 KiB
TypeScript
import {
|
||
lazy,
|
||
memo,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { useAppTranslation } from '@features/localization/renderer';
|
||
import { api } from '@renderer/api';
|
||
import { SessionPanel } from '@renderer/components/chat/session-panel';
|
||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||
import { Button } from '@renderer/components/ui/button';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@renderer/components/ui/dialog';
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||
import { useOptionalTabId } from '@renderer/hooks/useOptionalTabId';
|
||
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
|
||
import { useTheme } from '@renderer/hooks/useTheme';
|
||
import { cn } from '@renderer/lib/utils';
|
||
import { useStore } from '@renderer/store';
|
||
import {
|
||
getCurrentProvisioningProgressForTeam,
|
||
isTeamProvisioningActive,
|
||
selectResolvedMemberForTeamName,
|
||
selectResolvedMembersForTeamName,
|
||
selectTeamMemberSnapshotsForName,
|
||
} from '@renderer/store/slices/teamSlice';
|
||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||
import * as tokenMath from '@renderer/utils/contextMath';
|
||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||
import {
|
||
hasUnresolvedMemberSpawnStatus,
|
||
MEMBER_SPAWN_STATUS_REFRESH_MS,
|
||
} from '@renderer/utils/memberSpawnStatusPolling';
|
||
import { shouldClearPendingReplyForOpenCodeRuntimeDelivery } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||
import {
|
||
buildTaskChangeRequestOptions,
|
||
type TaskChangeRequestOptions,
|
||
} from '@renderer/utils/taskChangeRequest';
|
||
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
|
||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||
import {
|
||
AlertTriangle,
|
||
ChevronRight,
|
||
Clock,
|
||
Code,
|
||
Columns3,
|
||
Expand,
|
||
FolderOpen,
|
||
GitBranch,
|
||
History,
|
||
MessageSquare,
|
||
MoreHorizontal,
|
||
Network,
|
||
Paperclip,
|
||
Pencil,
|
||
Play,
|
||
Plus,
|
||
Square,
|
||
Terminal,
|
||
Trash2,
|
||
UserPlus,
|
||
Users,
|
||
} from 'lucide-react';
|
||
import { useShallow } from 'zustand/react/shallow';
|
||
|
||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||
import { LaunchTeamDialogLoadingFallback } from './dialogs/LaunchTeamDialogLoadingFallback';
|
||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||
import { KanbanBoard } from './kanban/KanbanBoard';
|
||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||
import { KanbanSearchInput } from './kanban/KanbanSearchInput';
|
||
import { TrashDialog } from './kanban/TrashDialog';
|
||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
|
||
import { deriveMetrics } from './context-metric-alias';
|
||
|
||
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
||
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
||
import type { TeamColorSet } from '@renderer/constants/teamColors';
|
||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||
import type { ComponentProps, CSSProperties, RefObject } from 'react';
|
||
|
||
const sumInjectionTokens = tokenMath[
|
||
['sum', 'Con' + 'text', 'InjectionTokens'].join('') as keyof typeof tokenMath
|
||
] as (injections: readonly unknown[]) => number;
|
||
const LaunchTeamDialog = lazy(() =>
|
||
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
||
);
|
||
const ProjectEditorOverlay = lazy(() =>
|
||
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
||
);
|
||
const TeamGraphOverlay = lazy(() =>
|
||
import('@features/agent-graph/renderer').then((m) => ({
|
||
default: m.TeamGraphOverlay,
|
||
}))
|
||
);
|
||
const TaskDetailDialog = lazy(() =>
|
||
import('./dialogs/TaskDetailDialog').then((m) => ({ default: m.TaskDetailDialog }))
|
||
);
|
||
const SendMessageDialog = lazy(() =>
|
||
import('./dialogs/SendMessageDialog').then((m) => ({ default: m.SendMessageDialog }))
|
||
);
|
||
const CreateTaskDialog = lazy(() =>
|
||
import('./dialogs/CreateTaskDialog').then((m) => ({ default: m.CreateTaskDialog }))
|
||
);
|
||
const ChangeReviewDialog = lazy(() =>
|
||
import('./review/ChangeReviewDialog').then((m) => ({ default: m.ChangeReviewDialog }))
|
||
);
|
||
import { MemberList } from './members/MemberList';
|
||
import { MessagesPanel } from './messages/MessagesPanel';
|
||
import { ScheduleSection } from './schedule/ScheduleSection';
|
||
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
||
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
||
import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
||
import {
|
||
getTeamPendingRepliesState,
|
||
setTeamPendingRepliesState,
|
||
} from './sidebar/teamSidebarUiState';
|
||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||
import { deriveLeadLoadButtonLabel } from './lead-load-guards';
|
||
import { LeadSessionDetailGate } from './LeadSessionDetailGate';
|
||
import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge';
|
||
import { ProcessesSection } from './ProcessesSection';
|
||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
||
import { TeamChangesSection } from './TeamChangesSection';
|
||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
|
||
import { TeamSessionsSection } from './TeamSessionsSection';
|
||
import { useTeamAgentRuntimeWatcher } from './useTeamAgentRuntimeWatcher';
|
||
|
||
import type { UsageLike } from './context-metric-alias';
|
||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||
import type { KanbanSortState } from './kanban/KanbanSortPopover';
|
||
import type { SessionInjection } from './session-injection-types';
|
||
import type { Session } from '@renderer/types/data';
|
||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||
import type {
|
||
MemberSpawnStatusEntry,
|
||
ResolvedTeamMember,
|
||
TaskRef,
|
||
TeamAgentRuntimeEntry,
|
||
TeamCreateRequest,
|
||
TeamLaunchRequest,
|
||
TeamProviderId,
|
||
TeamSummary,
|
||
TeamTaskWithKanban,
|
||
} from '@shared/types';
|
||
import type { EditorSelectionAction } from '@shared/types/editor';
|
||
|
||
interface TeamDetailViewProps {
|
||
teamName: string;
|
||
isActive?: boolean;
|
||
isPaneFocused?: boolean;
|
||
}
|
||
|
||
interface CreateTaskDialogState {
|
||
open: boolean;
|
||
defaultSubject: string;
|
||
defaultDescription: string;
|
||
defaultOwner: string;
|
||
defaultStartImmediately?: boolean;
|
||
defaultChip?: InlineChip;
|
||
}
|
||
|
||
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
|
||
const MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200;
|
||
const FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX = 200;
|
||
|
||
function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number {
|
||
if (!summary) {
|
||
return 0;
|
||
}
|
||
|
||
const normalizedLeadName = summary.leadName?.trim().toLowerCase();
|
||
const rosterNames = new Set<string>();
|
||
for (const member of summary.members ?? []) {
|
||
const name = member.name?.trim();
|
||
if (!name) {
|
||
continue;
|
||
}
|
||
const normalizedName = name.toLowerCase();
|
||
if (
|
||
normalizedName === 'user' ||
|
||
isLeadMember({ name }) ||
|
||
(normalizedLeadName && normalizedName === normalizedLeadName)
|
||
) {
|
||
continue;
|
||
}
|
||
rosterNames.add(normalizedName);
|
||
}
|
||
|
||
const launchNames = new Set<string>();
|
||
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
|
||
const name = rawName.trim();
|
||
if (!name) {
|
||
continue;
|
||
}
|
||
const normalizedName = name.toLowerCase();
|
||
if (
|
||
normalizedName === 'user' ||
|
||
isLeadMember({ name }) ||
|
||
(normalizedLeadName && normalizedName === normalizedLeadName)
|
||
) {
|
||
continue;
|
||
}
|
||
launchNames.add(normalizedName);
|
||
}
|
||
|
||
const activeRosterCount = Math.max(summary.memberCount, rosterNames.size);
|
||
if (activeRosterCount > 0) {
|
||
return activeRosterCount;
|
||
}
|
||
|
||
return Math.max(
|
||
summary.expectedMemberCount ?? 0,
|
||
launchNames.size,
|
||
(summary.confirmedCount ?? 0) +
|
||
(summary.pendingCount ?? 0) +
|
||
(summary.failedCount ?? 0) +
|
||
(summary.skippedCount ?? 0)
|
||
);
|
||
}
|
||
|
||
function areResolvedMembersEqual(
|
||
prev: readonly ResolvedTeamMember[],
|
||
next: readonly ResolvedTeamMember[]
|
||
): boolean {
|
||
if (prev === next) return true;
|
||
if (prev.length !== next.length) return false;
|
||
|
||
for (let i = 0; i < prev.length; i++) {
|
||
const prevMember = prev[i];
|
||
const nextMember = next[i];
|
||
if (
|
||
prevMember.name !== nextMember.name ||
|
||
prevMember.status !== nextMember.status ||
|
||
prevMember.currentTaskId !== nextMember.currentTaskId ||
|
||
prevMember.color !== nextMember.color ||
|
||
prevMember.agentType !== nextMember.agentType ||
|
||
prevMember.role !== nextMember.role ||
|
||
prevMember.workflow !== nextMember.workflow ||
|
||
prevMember.providerId !== nextMember.providerId ||
|
||
prevMember.model !== nextMember.model ||
|
||
prevMember.effort !== nextMember.effort ||
|
||
prevMember.cwd !== nextMember.cwd ||
|
||
prevMember.gitBranch !== nextMember.gitBranch ||
|
||
prevMember.removedAt !== nextMember.removedAt ||
|
||
prevMember.runtimeAdvisory?.kind !== nextMember.runtimeAdvisory?.kind ||
|
||
prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt ||
|
||
prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil ||
|
||
prevMember.runtimeAdvisory?.retryDelayMs !== nextMember.runtimeAdvisory?.retryDelayMs ||
|
||
prevMember.runtimeAdvisory?.reasonCode !== nextMember.runtimeAdvisory?.reasonCode ||
|
||
prevMember.runtimeAdvisory?.message !== nextMember.runtimeAdvisory?.message
|
||
) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function useStableActiveMembers(
|
||
members: readonly ResolvedTeamMember[] | undefined
|
||
): ResolvedTeamMember[] {
|
||
const filteredMembers = useMemo(
|
||
() => (members ?? []).filter((member) => !member.removedAt),
|
||
[members]
|
||
);
|
||
const stableMembersRef = useRef(filteredMembers);
|
||
|
||
if (!areResolvedMembersEqual(stableMembersRef.current, filteredMembers)) {
|
||
stableMembersRef.current = filteredMembers;
|
||
}
|
||
|
||
return stableMembersRef.current;
|
||
}
|
||
|
||
interface TimeWindow {
|
||
start: number;
|
||
end: number;
|
||
}
|
||
|
||
function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] {
|
||
if (query.startsWith('#')) {
|
||
const id = query.slice(1);
|
||
return tasks.filter((t) => t.id === id || t.displayId === id);
|
||
}
|
||
const lower = query.toLowerCase();
|
||
return tasks.filter(
|
||
(t) =>
|
||
t.id.toLowerCase().includes(lower) ||
|
||
(t.displayId?.toLowerCase().includes(lower) ?? false) ||
|
||
t.subject.toLowerCase().includes(lower) ||
|
||
(t.owner?.toLowerCase().includes(lower) ?? false)
|
||
);
|
||
}
|
||
|
||
const TEAM_LOADING_MEMBER_ACCENTS = ['#46d93b', '#3b82f6', '#facc15', '#14b8a6', '#ef4444'];
|
||
|
||
const TEAM_LOADING_KANBAN_COLUMNS = [
|
||
{
|
||
id: 'todo',
|
||
headerBg: 'rgba(59, 130, 246, 0.28)',
|
||
bodyBg: 'rgba(59, 130, 246, 0.06)',
|
||
},
|
||
{
|
||
id: 'inProgress',
|
||
headerBg: 'rgba(234, 179, 8, 0.28)',
|
||
bodyBg: 'rgba(234, 179, 8, 0.07)',
|
||
},
|
||
{
|
||
id: 'review',
|
||
headerBg: 'rgba(139, 92, 246, 0.28)',
|
||
bodyBg: 'rgba(139, 92, 246, 0.07)',
|
||
},
|
||
] as const;
|
||
|
||
type SkeletonClassNameProps = Readonly<{ className?: string }>;
|
||
|
||
const SkeletonBlock = ({ className }: SkeletonClassNameProps): React.JSX.Element => (
|
||
<div
|
||
aria-hidden="true"
|
||
className={cn('animate-pulse rounded-md bg-[var(--color-surface-raised)]', className)}
|
||
/>
|
||
);
|
||
|
||
const SkeletonPill = ({ className }: SkeletonClassNameProps): React.JSX.Element => (
|
||
<div
|
||
aria-hidden="true"
|
||
className={cn('animate-pulse rounded-full bg-[var(--color-surface-raised)]', className)}
|
||
/>
|
||
);
|
||
|
||
const TeamLoadingMessageComposerSkeleton = (): React.JSX.Element => (
|
||
<div className="relative mb-1.5 pb-1.5" aria-hidden="true">
|
||
<div className="mb-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-flex size-[22px] shrink-0 items-center justify-center rounded p-1 text-[var(--color-text-muted)] opacity-70">
|
||
<Paperclip size={14} />
|
||
</span>
|
||
<SkeletonPill className="h-3 w-20 rounded bg-yellow-500/20" />
|
||
<div className="ml-auto mr-[15px] inline-flex h-[26px] shrink-0 items-center overflow-hidden rounded-b-none rounded-t-[1.35rem] border border-b-0 border-[var(--color-border)] bg-[var(--color-surface-raised)]">
|
||
<div className="flex h-full items-center gap-1.5 border-r border-r-[var(--color-border)] px-2.5">
|
||
<SkeletonPill className="size-2 bg-[var(--skeleton-base-dim)]" />
|
||
<SkeletonPill className="h-3 w-16 bg-[var(--skeleton-base-dim)]" />
|
||
<SkeletonPill className="size-3 rounded bg-[var(--skeleton-base-dim)]" />
|
||
</div>
|
||
<div className="flex h-full items-center gap-1.5 px-2.5">
|
||
<SkeletonPill className="h-4 w-14 bg-[var(--skeleton-base-dim)]" />
|
||
<SkeletonPill className="size-3 rounded bg-[var(--skeleton-base-dim)]" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="relative z-[2]">
|
||
<div className="message-composer-shell relative h-[98px] overflow-hidden rounded-md border border-transparent bg-[var(--color-surface-raised)] shadow-[0_8px_24px_rgba(0,0,0,0.18),inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||
<div className="pointer-events-none absolute inset-0 rounded-md border border-[var(--color-border-emphasis)]" />
|
||
<SkeletonPill className="absolute left-3 top-3 h-3 w-[62%] rounded bg-[var(--skeleton-base-dim)]" />
|
||
<SkeletonPill className="absolute left-3 top-8 h-3 w-[42%] rounded bg-[var(--skeleton-base-dim)]" />
|
||
<SkeletonPill className="absolute bottom-2 left-2 h-5 w-[68px] border border-[var(--color-border)] bg-[var(--skeleton-base-dim)]" />
|
||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||
<SkeletonPill className="size-[26px] bg-[var(--skeleton-base-dim)]" />
|
||
<SkeletonPill className="h-[30px] w-[72px] bg-blue-600/35" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-1 flex items-start justify-between gap-2">
|
||
<SkeletonPill className="h-3 w-56 max-w-[68%] rounded bg-[var(--skeleton-base-dim)]" />
|
||
<SkeletonPill className="h-3 w-12 rounded bg-[var(--skeleton-base-dim)]" />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const TeamLoadingSidebarSkeleton = (): React.JSX.Element => {
|
||
const { t } = useAppTranslation('team');
|
||
|
||
return (
|
||
<aside
|
||
className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]"
|
||
aria-label={t('detail.loadingSidebar')}
|
||
>
|
||
<div className="shrink-0 overflow-hidden px-3">
|
||
<section className="min-w-0">
|
||
<div className="relative -mx-3 flex min-h-9 w-[calc(100%+1.5rem)] items-stretch py-0">
|
||
<div className="absolute inset-0 z-0 bg-[var(--color-section-bg)]" />
|
||
<div className="relative z-10 flex min-w-0 flex-1 basis-0 flex-wrap items-center gap-2 gap-y-1 py-1 pl-4 pr-1">
|
||
<ChevronRight
|
||
size={14}
|
||
className="shrink-0 text-[var(--color-text-muted)] transition-transform duration-150"
|
||
/>
|
||
<SkeletonPill className="h-4 w-14" />
|
||
<SkeletonPill className="h-5 w-14" />
|
||
<span className="pointer-events-auto ml-auto inline-flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] opacity-70">
|
||
<Expand size={14} />
|
||
</span>
|
||
<span className="flex min-w-0 basis-full items-center gap-1.5 opacity-70">
|
||
<MessageSquare size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||
<SkeletonPill className="h-3 w-12 rounded" />
|
||
<SkeletonPill className="h-3 w-2 rounded" />
|
||
<SkeletonPill className="h-3 min-w-0 flex-1 rounded" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div className="bg-[var(--color-text-muted)]/35 h-px shrink-0" />
|
||
<div className="min-h-0 flex-1">
|
||
<div className="flex size-full flex-col overflow-hidden bg-[var(--color-surface-sidebar)]">
|
||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-3 py-2">
|
||
<MessageSquare size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
||
<SkeletonPill className="h-4 w-24" />
|
||
<SkeletonPill className="h-5 w-8" />
|
||
<span className="ml-auto inline-flex size-7 items-center justify-center rounded text-[var(--color-text-muted)] opacity-70">
|
||
<MoreHorizontal size={15} />
|
||
</span>
|
||
</div>
|
||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden pb-14 pr-3 pt-2">
|
||
<div className="pl-3">
|
||
<TeamLoadingMessageComposerSkeleton />
|
||
</div>
|
||
<div className="space-y-3 overflow-hidden pl-3">
|
||
{[0, 1, 2].map((index) => (
|
||
<div
|
||
key={index}
|
||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-sidebar)] p-3"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<SkeletonPill className="h-5 w-12" />
|
||
<SkeletonPill className="h-3 w-16" />
|
||
<SkeletonPill className="ml-auto h-3 w-12" />
|
||
</div>
|
||
<SkeletonPill className="mt-5 h-4 w-[88%]" />
|
||
<SkeletonPill className="mt-2 h-4 w-[72%]" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
type TeamLoadingSectionHeaderProps = Readonly<{
|
||
icon: React.ReactNode;
|
||
titleWidth: string;
|
||
badgeWidth?: string;
|
||
actionWidth?: string;
|
||
open?: boolean;
|
||
}>;
|
||
|
||
const TeamLoadingSectionHeader = ({
|
||
icon,
|
||
titleWidth,
|
||
badgeWidth,
|
||
actionWidth,
|
||
open = true,
|
||
}: TeamLoadingSectionHeaderProps): React.JSX.Element => (
|
||
<div
|
||
className="relative flex min-h-9 items-stretch py-1.5"
|
||
style={{
|
||
marginInline: 'calc((1rem - 5px) * -1)',
|
||
width: 'calc(100% + 2rem - 10px)',
|
||
}}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'absolute inset-0 z-0',
|
||
open
|
||
? 'rounded-t-xl bg-[var(--color-section-bg-open)]'
|
||
: 'rounded-xl bg-[var(--color-section-bg)]'
|
||
)}
|
||
/>
|
||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2 pl-4">
|
||
<ChevronRight
|
||
size={14}
|
||
className={cn(
|
||
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
||
open && 'rotate-90'
|
||
)}
|
||
/>
|
||
<span className="shrink-0 text-[var(--color-text-muted)]">{icon}</span>
|
||
<SkeletonPill className={cn('h-4', titleWidth)} />
|
||
{badgeWidth ? <SkeletonPill className={cn('h-5', badgeWidth)} /> : null}
|
||
</div>
|
||
{actionWidth ? (
|
||
<div className="relative z-10 flex shrink-0 items-center pr-3">
|
||
<SkeletonPill className={cn('h-5', actionWidth)} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
type TeamContentLoadingSkeletonProps = Readonly<{
|
||
teamName: string;
|
||
headerColorSet: TeamColorSet;
|
||
isLight: boolean;
|
||
contentRef: RefObject<HTMLDivElement | null>;
|
||
provisioningBannerRef: RefObject<HTMLDivElement | null>;
|
||
}>;
|
||
|
||
const TeamContentLoadingSkeleton = ({
|
||
teamName,
|
||
headerColorSet,
|
||
isLight,
|
||
contentRef,
|
||
provisioningBannerRef,
|
||
}: TeamContentLoadingSkeletonProps): React.JSX.Element => {
|
||
const { t } = useAppTranslation('team');
|
||
|
||
return (
|
||
<div
|
||
ref={contentRef}
|
||
className="size-full min-w-0 overflow-y-auto overflow-x-hidden p-4"
|
||
data-team-name={teamName}
|
||
role="status"
|
||
aria-label={t('detail.loading')}
|
||
>
|
||
<div className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] px-4 py-3">
|
||
<div
|
||
className="pointer-events-none absolute inset-0 z-0"
|
||
style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }}
|
||
/>
|
||
<div className="relative z-10 flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex h-6 items-center gap-2">
|
||
<SkeletonPill className="h-5 w-44" />
|
||
<SkeletonPill className="h-5 w-20 bg-emerald-500/15" />
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
<SkeletonPill className="h-7 w-16" />
|
||
<SkeletonPill className="size-7 rounded" />
|
||
<SkeletonPill className="size-7 rounded" />
|
||
</div>
|
||
</div>
|
||
<SkeletonPill className="relative z-10 mt-0.5 h-3 w-72 max-w-full" />
|
||
<div className="relative z-10 mt-1 flex items-start justify-between gap-3">
|
||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5">
|
||
<SkeletonPill className="h-3 w-28" />
|
||
<SkeletonPill className="h-5 w-24 rounded-md" />
|
||
<SkeletonPill className="h-3 w-16" />
|
||
</div>
|
||
<SkeletonPill className="-mt-2 h-8 w-24 shrink-0 rounded-full border border-cyan-300/25 bg-cyan-500/10" />
|
||
</div>
|
||
</div>
|
||
|
||
<div ref={provisioningBannerRef}>
|
||
<TeamProvisioningBanner teamName={teamName} />
|
||
</div>
|
||
|
||
<section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
|
||
<TeamLoadingSectionHeader
|
||
icon={<Users size={14} />}
|
||
titleWidth="w-20"
|
||
badgeWidth="w-8"
|
||
actionWidth="w-20"
|
||
/>
|
||
<div className="mt-3 grid grid-cols-1 gap-1 pb-4">
|
||
{TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
|
||
<div key={accent} className="flex min-h-[52px] min-w-0 items-center gap-2.5">
|
||
<div className="relative size-[34px] shrink-0">
|
||
<div
|
||
className="absolute inset-0 rounded-full border-2 bg-[var(--color-surface-raised)]"
|
||
style={{
|
||
borderColor: accent,
|
||
boxShadow: isLight ? 'none' : `0 0 0 1px ${accent}26`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)]"
|
||
style={{ backgroundColor: accent }}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<SkeletonPill
|
||
className={cn('h-4', index === 0 ? 'w-14' : index === 3 ? 'w-16' : 'w-12')}
|
||
/>
|
||
<SkeletonPill
|
||
className={cn(
|
||
'mt-1.5 h-2.5',
|
||
index === 1 ? 'w-60' : index === 4 ? 'w-64' : 'w-52'
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="hidden shrink-0 items-center gap-3 sm:flex">
|
||
<SkeletonPill className="h-[18px] w-[62px]" />
|
||
<SkeletonPill className="h-[18px] w-[62px]" />
|
||
<SkeletonPill className="size-[21px] rounded" />
|
||
<SkeletonPill className="size-[21px] rounded" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
|
||
<TeamLoadingSectionHeader icon={<History size={14} />} titleWidth="w-24" open={false} />
|
||
</section>
|
||
|
||
<section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
|
||
<TeamLoadingSectionHeader
|
||
icon={<Columns3 size={14} />}
|
||
titleWidth="w-24"
|
||
badgeWidth="w-8"
|
||
actionWidth="w-16"
|
||
/>
|
||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||
<div className="relative h-9 min-w-[220px] max-w-sm flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-sidebar)]">
|
||
<SkeletonPill className="absolute left-3 top-1/2 size-4 -translate-y-1/2 rounded" />
|
||
<SkeletonPill className="absolute left-10 top-1/2 h-4 w-44 -translate-y-1/2" />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<SkeletonBlock className="h-9 w-20" />
|
||
<SkeletonBlock className="h-9 w-28" />
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-12 gap-3">
|
||
{TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
|
||
<div
|
||
key={column.id}
|
||
className="col-span-4 flex h-[400px] min-h-0 flex-col overflow-hidden rounded-md border border-[var(--color-border)]"
|
||
style={{ backgroundColor: column.bodyBg }}
|
||
>
|
||
<div
|
||
className="flex shrink-0 items-center gap-2 px-3 py-2"
|
||
style={{ backgroundColor: column.headerBg }}
|
||
>
|
||
<SkeletonPill className="size-4 rounded" />
|
||
<SkeletonPill className={cn('h-4', column.id === 'inProgress' ? 'w-32' : 'w-20')} />
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-hidden p-2">
|
||
<div
|
||
className="flex h-12 items-center justify-center rounded-md border border-dashed border-[var(--color-border)]"
|
||
style={{
|
||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 35%, transparent)',
|
||
}}
|
||
>
|
||
<SkeletonPill className="h-4 w-28" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
type TeamLoadingSkeletonProps = Readonly<{
|
||
teamName: string;
|
||
isActive: boolean | undefined;
|
||
isFocused: boolean | undefined;
|
||
messagesPanelMode: TeamMessagesPanelMode;
|
||
headerColorSet: TeamColorSet;
|
||
isLight: boolean;
|
||
contentRef: RefObject<HTMLDivElement | null>;
|
||
provisioningBannerRef: RefObject<HTMLDivElement | null>;
|
||
}>;
|
||
|
||
const TeamLoadingSkeleton = ({
|
||
teamName,
|
||
isActive,
|
||
isFocused,
|
||
messagesPanelMode,
|
||
headerColorSet,
|
||
isLight,
|
||
contentRef,
|
||
provisioningBannerRef,
|
||
}: TeamLoadingSkeletonProps): React.JSX.Element => (
|
||
<div className="flex size-full overflow-hidden">
|
||
{messagesPanelMode === 'sidebar' ? (
|
||
<TeamSidebarHost
|
||
teamName={teamName}
|
||
surface="team"
|
||
isActive={Boolean(isActive)}
|
||
isFocused={Boolean(isFocused)}
|
||
>
|
||
<TeamLoadingSidebarSkeleton />
|
||
</TeamSidebarHost>
|
||
) : null}
|
||
<div className="relative min-h-0 min-w-0 flex-1">
|
||
<TeamContentLoadingSkeleton
|
||
teamName={teamName}
|
||
headerColorSet={headerColorSet}
|
||
isLight={isLight}
|
||
contentRef={contentRef}
|
||
provisioningBannerRef={provisioningBannerRef}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
||
teamName,
|
||
onLaunch,
|
||
}: {
|
||
teamName: string;
|
||
onLaunch: () => void;
|
||
}): React.JSX.Element {
|
||
const { t } = useAppTranslation('team');
|
||
const summary = useStore(
|
||
useShallow((s) => {
|
||
const team = s.teamByName[teamName];
|
||
if (!team) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
memberCount: team.memberCount,
|
||
expectedMemberCount: team.expectedMemberCount,
|
||
confirmedCount: team.confirmedCount,
|
||
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||
teamLaunchState: team.teamLaunchState,
|
||
partialLaunchFailure: team.partialLaunchFailure,
|
||
missingMemberCount: team.missingMembers?.length ?? 0,
|
||
};
|
||
})
|
||
);
|
||
|
||
const message =
|
||
summary?.teamLaunchState === 'partial_pending'
|
||
? summary.runtimeProcessPendingCount != null && summary.runtimeProcessPendingCount > 0
|
||
? buildPendingRuntimeSummaryCopy({
|
||
confirmedCount: summary.confirmedCount,
|
||
expectedMemberCount: summary.expectedMemberCount,
|
||
memberCount: summary.memberCount,
|
||
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
|
||
})
|
||
: t('detail.offline.reconciling')
|
||
: summary?.partialLaunchFailure
|
||
? summary.missingMemberCount > 0
|
||
? t('detail.offline.partialMissing', {
|
||
missing: summary.missingMemberCount,
|
||
expected: summary.expectedMemberCount ?? summary.missingMemberCount,
|
||
})
|
||
: t('detail.offline.partialFailed')
|
||
: t('detail.offline.offline');
|
||
|
||
return (
|
||
<div
|
||
className="mb-3 flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||
style={{
|
||
backgroundColor: 'var(--warning-bg)',
|
||
borderColor: 'var(--warning-border)',
|
||
color: 'var(--warning-text)',
|
||
}}
|
||
>
|
||
<span className="flex items-center gap-1.5 text-xs">
|
||
<AlertTriangle size={14} className="shrink-0" />
|
||
{message}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 shrink-0 gap-1 px-2 text-xs text-[var(--step-done-text)] hover:bg-[var(--step-done-bg)]"
|
||
onClick={onLaunch}
|
||
>
|
||
<Play size={12} />
|
||
{t('detail.actions.launch')}
|
||
</Button>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
type LeadUpdatedKey = `lead${'Con'}${'text'}UpdatedAt`;
|
||
type TeamMessagesPanelBridgeProps = Omit<
|
||
ComponentProps<typeof MessagesPanel>,
|
||
'leadActivity' | LeadUpdatedKey
|
||
>;
|
||
type SharedTeamMessagesPanelProps = Omit<TeamMessagesPanelBridgeProps, 'position'>;
|
||
type TeamMemberListBridgeProps = Omit<
|
||
ComponentProps<typeof MemberList>,
|
||
'leadActivity' | 'memberSpawnStatuses'
|
||
> & {
|
||
teamName: string;
|
||
};
|
||
type TeamMemberDetailDialogBridgeProps = Omit<
|
||
ComponentProps<typeof MemberDetailDialog>,
|
||
'leadActivity' | 'spawnEntry' | 'runtimeEntry'
|
||
>;
|
||
type TeamSidebarRailBridgeProps = Omit<
|
||
ComponentProps<typeof TeamSidebarRail>,
|
||
'messagesPanelProps'
|
||
> & {
|
||
messagesPanelProps: SharedTeamMessagesPanelProps;
|
||
};
|
||
interface LeadLoadBridgeProps {
|
||
teamName: string;
|
||
tabId: string | null;
|
||
projectId: string | null;
|
||
leadSessionId: string | null;
|
||
leadProviderId?: TeamProviderId;
|
||
fallbackProjectRoot?: string;
|
||
isThisTabActive: boolean;
|
||
}
|
||
|
||
const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = [];
|
||
|
||
function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string {
|
||
return JSON.stringify(
|
||
tasks.map((task) => [
|
||
task.id,
|
||
task.displayId ?? '',
|
||
task.subject,
|
||
task.owner ?? '',
|
||
task.reviewer ?? '',
|
||
task.status,
|
||
task.reviewState ?? '',
|
||
task.kanbanColumn ?? '',
|
||
])
|
||
);
|
||
}
|
||
|
||
function useStableMessagesPanelTasks(
|
||
tasks: TeamTaskWithKanban[] | undefined
|
||
): TeamTaskWithKanban[] {
|
||
const sourceTasks = tasks ?? EMPTY_MESSAGES_PANEL_TASKS;
|
||
const signature = useMemo(() => buildMessagesPanelTasksSignature(sourceTasks), [sourceTasks]);
|
||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sourceTasks identity is gated by render-relevant task fields.
|
||
return useMemo(() => sourceTasks, [signature]);
|
||
}
|
||
|
||
// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet.
|
||
const LEAD_LOAD_UNSUPPORTED_PROVIDER_IDS = new Set<TeamProviderId>(['codex', 'opencode']);
|
||
|
||
function canShowLeadLoadUi(providerId: TeamProviderId | undefined): boolean {
|
||
return providerId === undefined || !LEAD_LOAD_UNSUPPORTED_PROVIDER_IDS.has(providerId);
|
||
}
|
||
|
||
function buildMemberSpawnStatusMap(
|
||
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
|
||
): Map<string, MemberSpawnStatusEntry> | undefined {
|
||
if (!memberSpawnStatuses) {
|
||
return undefined;
|
||
}
|
||
|
||
const map = new Map<string, MemberSpawnStatusEntry>(Object.entries(memberSpawnStatuses));
|
||
return map.size > 0 ? map : undefined;
|
||
}
|
||
|
||
function buildTeamAgentRuntimeMap(
|
||
runtimeSnapshot: Record<string, TeamAgentRuntimeEntry> | undefined
|
||
): Map<string, TeamAgentRuntimeEntry> | undefined {
|
||
if (!runtimeSnapshot) {
|
||
return undefined;
|
||
}
|
||
|
||
const map = new Map<string, TeamAgentRuntimeEntry>(Object.entries(runtimeSnapshot));
|
||
return map.size > 0 ? map : undefined;
|
||
}
|
||
|
||
const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
||
teamName,
|
||
isTeamProvisioning,
|
||
isTeamAlive,
|
||
isThisTabActive,
|
||
}: {
|
||
teamName: string;
|
||
isTeamProvisioning: boolean;
|
||
isTeamAlive?: boolean;
|
||
isThisTabActive: boolean;
|
||
}): null {
|
||
const { leadActivity, memberSpawnStatuses, memberSpawnSnapshot, fetchMemberSpawnStatuses } =
|
||
useStore(
|
||
useShallow((s) => ({
|
||
leadActivity: s.leadActivityByTeam[teamName],
|
||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
||
}))
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!isThisTabActive) return;
|
||
|
||
const hasUnresolvedSpawn = hasUnresolvedMemberSpawnStatus(
|
||
memberSpawnStatuses,
|
||
memberSpawnSnapshot
|
||
);
|
||
const shouldFetchSpawnStatuses =
|
||
isTeamProvisioning ||
|
||
hasUnresolvedSpawn ||
|
||
(memberSpawnStatuses == null &&
|
||
(isTeamAlive === true || leadActivity === 'active' || leadActivity === 'idle'));
|
||
if (shouldFetchSpawnStatuses) {
|
||
void fetchMemberSpawnStatuses(teamName);
|
||
}
|
||
|
||
if (!isTeamProvisioning && !hasUnresolvedSpawn) {
|
||
return;
|
||
}
|
||
|
||
const interval = window.setInterval(() => {
|
||
void fetchMemberSpawnStatuses(teamName);
|
||
}, MEMBER_SPAWN_STATUS_REFRESH_MS);
|
||
return () => {
|
||
window.clearInterval(interval);
|
||
};
|
||
}, [
|
||
fetchMemberSpawnStatuses,
|
||
isTeamAlive,
|
||
isTeamProvisioning,
|
||
isThisTabActive,
|
||
leadActivity,
|
||
memberSpawnSnapshot,
|
||
memberSpawnStatuses,
|
||
teamName,
|
||
]);
|
||
|
||
return null;
|
||
});
|
||
|
||
const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
|
||
teamName,
|
||
isTeamProvisioning,
|
||
isTeamAlive,
|
||
isThisTabActive,
|
||
}: {
|
||
teamName: string;
|
||
isTeamProvisioning: boolean;
|
||
isTeamAlive?: boolean;
|
||
isThisTabActive: boolean;
|
||
}): null {
|
||
useTeamAgentRuntimeWatcher({
|
||
teamName,
|
||
enabled: isThisTabActive,
|
||
isTeamAlive,
|
||
isTeamProvisioning,
|
||
});
|
||
|
||
return null;
|
||
});
|
||
|
||
const LeadLoadBridge = memo(function LeadLoadBridge({
|
||
teamName,
|
||
tabId,
|
||
projectId,
|
||
leadSessionId,
|
||
leadProviderId,
|
||
fallbackProjectRoot,
|
||
isThisTabActive,
|
||
}: LeadLoadBridgeProps): React.JSX.Element | null {
|
||
const { t } = useAppTranslation('team');
|
||
const {
|
||
leadTabData,
|
||
leadContextSnapshot,
|
||
isContextPanelVisible,
|
||
selectedContextPhase,
|
||
setContextPanelVisibleForTab,
|
||
setSelectedContextPhaseForTab,
|
||
} = useStore(
|
||
useShallow((s) => ({
|
||
leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null,
|
||
leadContextSnapshot: s.leadContextByTeam[teamName] ?? null,
|
||
isContextPanelVisible: tabId ? (s.tabUIStates.get(tabId)?.showContextPanel ?? false) : false,
|
||
selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null,
|
||
setContextPanelVisibleForTab: s.setContextPanelVisibleForTab,
|
||
setSelectedContextPhaseForTab: s.setSelectedContextPhaseForTab,
|
||
}))
|
||
);
|
||
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
|
||
|
||
const setContextPanelVisible = useCallback(
|
||
(visible: boolean) => {
|
||
if (!tabId) return;
|
||
setContextPanelVisibleForTab(tabId, visible);
|
||
},
|
||
[setContextPanelVisibleForTab, tabId]
|
||
);
|
||
const setSelectedContextPhase = useCallback(
|
||
(phase: number | null) => {
|
||
if (!tabId) return;
|
||
setSelectedContextPhaseForTab(tabId, phase);
|
||
},
|
||
[setSelectedContextPhaseForTab, tabId]
|
||
);
|
||
|
||
const leadSessionDetail = leadTabData?.sessionDetail ?? null;
|
||
const leadConversation = leadTabData?.conversation ?? null;
|
||
const leadSessionContextStats = leadTabData?.sessionContextStats ?? null;
|
||
const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null;
|
||
const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false;
|
||
const leadSessionLoaded = Boolean(
|
||
leadSessionId && leadSessionDetail?.session?.id === leadSessionId
|
||
);
|
||
const leadSubagentCostUsd = useMemo(() => {
|
||
const processes = leadSessionDetail?.processes;
|
||
if (!processes || processes.length === 0) return undefined;
|
||
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
|
||
return total > 0 ? total : undefined;
|
||
}, [leadSessionDetail?.processes]);
|
||
const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => {
|
||
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
|
||
return {
|
||
allContextInjections: [] as SessionInjection[],
|
||
lastAssistantUsage: null as UsageLike | null,
|
||
lastAssistantModelName: undefined as string | undefined,
|
||
};
|
||
}
|
||
|
||
const effectivePhase = selectedContextPhase;
|
||
|
||
let targetAiGroupId: string | undefined;
|
||
if (effectivePhase !== null && leadSessionPhaseInfo) {
|
||
const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase);
|
||
if (phase) {
|
||
targetAiGroupId = phase.lastAIGroupId;
|
||
}
|
||
}
|
||
|
||
if (!targetAiGroupId) {
|
||
const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai');
|
||
if (lastAiItem?.type !== 'ai') {
|
||
return {
|
||
allContextInjections: [] as SessionInjection[],
|
||
lastAssistantUsage: null,
|
||
lastAssistantModelName: undefined,
|
||
};
|
||
}
|
||
targetAiGroupId = lastAiItem.group.id;
|
||
}
|
||
|
||
const stats = leadSessionContextStats.get(targetAiGroupId);
|
||
const injections = stats?.accumulatedInjections ?? [];
|
||
|
||
let lastUsage: UsageLike | null = null;
|
||
let lastModelName: string | undefined;
|
||
const targetItem = leadConversation.items.find(
|
||
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
||
);
|
||
if (targetItem?.type === 'ai') {
|
||
const responses = targetItem.group.responses || [];
|
||
for (let i = responses.length - 1; i >= 0; i--) {
|
||
const msg = responses[i];
|
||
if (msg.type === 'assistant' && msg.usage) {
|
||
lastUsage = msg.usage;
|
||
lastModelName = msg.model;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
allContextInjections: injections,
|
||
lastAssistantUsage: lastUsage,
|
||
lastAssistantModelName: lastModelName,
|
||
};
|
||
}, [
|
||
leadConversation,
|
||
leadSessionContextStats,
|
||
leadSessionLoaded,
|
||
leadSessionPhaseInfo,
|
||
selectedContextPhase,
|
||
]);
|
||
const visibleContextTokens = useMemo(
|
||
() => sumInjectionTokens(allContextInjections),
|
||
[allContextInjections]
|
||
);
|
||
const contextMetrics = useMemo(
|
||
() =>
|
||
deriveMetrics({
|
||
usage: lastAssistantUsage,
|
||
modelName: lastAssistantModelName,
|
||
contextWindowTokens: leadContextSnapshot?.contextWindowTokens ?? null,
|
||
visibleContextTokens,
|
||
}),
|
||
[
|
||
lastAssistantModelName,
|
||
lastAssistantUsage,
|
||
leadContextSnapshot?.contextWindowTokens,
|
||
visibleContextTokens,
|
||
]
|
||
);
|
||
const contextUsedPercentLabel = useMemo(
|
||
() =>
|
||
deriveLeadLoadButtonLabel({
|
||
liveContextUsedPercent: leadContextSnapshot?.contextUsedPercent,
|
||
fullContextUsedPercent: contextMetrics.contextUsedPercentOfContextWindow,
|
||
contextPanelOpen: isContextPanelVisible,
|
||
}),
|
||
[
|
||
contextMetrics.contextUsedPercentOfContextWindow,
|
||
isContextPanelVisible,
|
||
leadContextSnapshot?.contextUsedPercent,
|
||
]
|
||
);
|
||
const shouldShowLeadContextUi = canShowLeadLoadUi(leadProviderId);
|
||
const shouldLoadFullLeadDetail = Boolean(
|
||
leadSessionId && shouldShowLeadContextUi && isThisTabActive && isContextPanelVisible
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!shouldShowLeadContextUi && isContextPanelVisible) {
|
||
setContextPanelVisible(false);
|
||
}
|
||
}, [isContextPanelVisible, setContextPanelVisible, shouldShowLeadContextUi]);
|
||
|
||
if (!leadSessionId || !shouldShowLeadContextUi) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<LeadSessionDetailGate
|
||
tabId={tabId}
|
||
projectId={projectId}
|
||
leadSessionId={leadSessionId}
|
||
enabled={shouldLoadFullLeadDetail}
|
||
/>
|
||
|
||
{isContextPanelVisible && (
|
||
<div className="w-80 shrink-0">
|
||
{leadSessionLoaded ? (
|
||
<SessionPanel
|
||
injections={allContextInjections}
|
||
onClose={() => setContextPanelVisible(false)}
|
||
projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot}
|
||
contextMetrics={contextMetrics}
|
||
sessionMetrics={leadSessionDetail?.metrics}
|
||
subagentCostUsd={leadSubagentCostUsd}
|
||
phaseInfo={leadSessionPhaseInfo ?? undefined}
|
||
selectedPhase={selectedContextPhase}
|
||
onPhaseChange={setSelectedContextPhase}
|
||
side="left"
|
||
/>
|
||
) : (
|
||
<div
|
||
className="flex h-full flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]"
|
||
style={{ backgroundColor: 'var(--color-surface)' }}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium text-[var(--color-text)]">
|
||
{t('detail.context.title')}
|
||
</p>
|
||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||
{leadSessionLoading
|
||
? t('detail.context.loading')
|
||
: t('detail.context.noSessionLoaded')}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||
onClick={() => setContextPanelVisible(false)}
|
||
aria-label={t('detail.context.closePanel', { team: teamName })}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-1 items-center justify-center p-4">
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
{leadSessionLoading
|
||
? t('detail.context.loadingContext')
|
||
: t('detail.context.openLeadSession')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className="pointer-events-none fixed bottom-4 z-20"
|
||
style={{ left: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
|
||
>
|
||
<button
|
||
onClick={() => {
|
||
setContextPanelVisible(!isContextPanelVisible);
|
||
}}
|
||
onMouseEnter={() => setIsContextButtonHovered(true)}
|
||
onMouseLeave={() => setIsContextButtonHovered(false)}
|
||
className="pointer-events-auto flex w-fit items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
|
||
style={{
|
||
backgroundColor: isContextPanelVisible
|
||
? 'var(--context-btn-active-bg)'
|
||
: isContextButtonHovered
|
||
? 'var(--context-btn-bg-hover)'
|
||
: 'var(--context-btn-bg)',
|
||
color: isContextPanelVisible
|
||
? 'var(--context-btn-active-text)'
|
||
: 'var(--color-text-secondary)',
|
||
}}
|
||
title={
|
||
leadSessionLoaded
|
||
? `Session: ${leadSessionId}`
|
||
: leadSessionLoading
|
||
? t('detail.context.loadingContext')
|
||
: leadSessionId
|
||
}
|
||
>
|
||
{contextUsedPercentLabel}
|
||
</button>
|
||
</div>
|
||
</>
|
||
);
|
||
});
|
||
|
||
const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||
teamName,
|
||
...props
|
||
}: TeamMemberListBridgeProps): React.JSX.Element {
|
||
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } =
|
||
useStore(
|
||
useShallow((s) => ({
|
||
leadActivity: s.leadActivityByTeam[teamName],
|
||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||
runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName],
|
||
}))
|
||
);
|
||
const memberSpawnStatusMap = useMemo(
|
||
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
|
||
[memberSpawnStatuses]
|
||
);
|
||
const memberRuntimeMap = useMemo(
|
||
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
||
[runtimeSnapshot?.members]
|
||
);
|
||
const runtimeEntries = runtimeSnapshot?.members;
|
||
const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId;
|
||
const isLaunchSettling = useMemo(() => {
|
||
if (progress?.state !== 'ready') {
|
||
return false;
|
||
}
|
||
return getLaunchJoinState(
|
||
getLaunchJoinMilestonesFromMembers({
|
||
members: props.members,
|
||
memberSpawnStatuses,
|
||
memberSpawnSnapshot,
|
||
memberRuntimeEntries: runtimeEntries,
|
||
})
|
||
).hasMembersStillJoining;
|
||
}, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members, runtimeEntries]);
|
||
|
||
return (
|
||
<MemberList
|
||
{...props}
|
||
teamName={teamName}
|
||
leadActivity={leadActivity}
|
||
memberSpawnStatuses={memberSpawnStatusMap}
|
||
memberRuntimeEntries={memberRuntimeMap}
|
||
runtimeRunId={runtimeRunId}
|
||
isLaunchSettling={isLaunchSettling}
|
||
/>
|
||
);
|
||
});
|
||
|
||
const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({
|
||
teamName,
|
||
...props
|
||
}: TeamMessagesPanelBridgeProps): React.JSX.Element {
|
||
const { leadActivity, leadContextUpdatedAt } = useStore(
|
||
useShallow((s) => ({
|
||
leadActivity: s.leadActivityByTeam[teamName],
|
||
leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt,
|
||
}))
|
||
);
|
||
|
||
return (
|
||
<MessagesPanel
|
||
{...props}
|
||
teamName={teamName}
|
||
leadActivity={leadActivity}
|
||
leadContextUpdatedAt={leadContextUpdatedAt}
|
||
/>
|
||
);
|
||
});
|
||
|
||
const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({
|
||
messagesPanelProps,
|
||
...props
|
||
}: TeamSidebarRailBridgeProps): React.JSX.Element {
|
||
const { leadActivity, leadContextUpdatedAt } = useStore(
|
||
useShallow((s) => ({
|
||
leadActivity: s.leadActivityByTeam[messagesPanelProps.teamName],
|
||
leadContextUpdatedAt: s.leadContextByTeam[messagesPanelProps.teamName]?.updatedAt,
|
||
}))
|
||
);
|
||
const bridgedMessagesPanelProps = useMemo(
|
||
() => ({
|
||
...messagesPanelProps,
|
||
leadActivity,
|
||
leadContextUpdatedAt,
|
||
}),
|
||
[leadActivity, leadContextUpdatedAt, messagesPanelProps]
|
||
);
|
||
|
||
return <TeamSidebarRail {...props} messagesPanelProps={bridgedMessagesPanelProps} />;
|
||
});
|
||
|
||
const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge({
|
||
teamName,
|
||
member,
|
||
...props
|
||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||
const {
|
||
leadActivity,
|
||
liveMember,
|
||
progress,
|
||
launchMembers,
|
||
memberSpawnStatuses,
|
||
memberSpawnSnapshot,
|
||
spawnEntry,
|
||
runtimeRunId,
|
||
runtimeEntries,
|
||
runtimeEntry,
|
||
} = useStore(
|
||
useShallow((s) => ({
|
||
leadActivity: s.leadActivityByTeam[teamName],
|
||
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
|
||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||
runtimeRunId:
|
||
s.teamAgentRuntimeByTeam[teamName]?.runId ??
|
||
s.memberSpawnSnapshotsByTeam[teamName]?.runId ??
|
||
getCurrentProvisioningProgressForTeam(s, teamName)?.runId,
|
||
runtimeEntries: s.teamAgentRuntimeByTeam[teamName]?.members,
|
||
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
||
}))
|
||
);
|
||
const isLaunchSettling = useMemo(() => {
|
||
if (progress?.state !== 'ready') {
|
||
return false;
|
||
}
|
||
return getLaunchJoinState(
|
||
getLaunchJoinMilestonesFromMembers({
|
||
members: launchMembers,
|
||
memberSpawnStatuses,
|
||
memberSpawnSnapshot,
|
||
memberRuntimeEntries: runtimeEntries,
|
||
})
|
||
).hasMembersStillJoining;
|
||
}, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state, runtimeEntries]);
|
||
|
||
return (
|
||
<MemberDetailDialog
|
||
{...props}
|
||
teamName={teamName}
|
||
member={liveMember ?? member}
|
||
isLaunchSettling={isLaunchSettling}
|
||
leadActivity={leadActivity}
|
||
spawnEntry={spawnEntry}
|
||
runtimeEntry={runtimeEntry}
|
||
runtimeRunId={runtimeRunId}
|
||
/>
|
||
);
|
||
});
|
||
|
||
export const TeamDetailView = memo(function TeamDetailView({
|
||
teamName,
|
||
isActive = true,
|
||
isPaneFocused = false,
|
||
}: TeamDetailViewProps): React.JSX.Element {
|
||
const { t } = useAppTranslation('team');
|
||
const { isLight } = useTheme();
|
||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||
const [selectedMemberView, setSelectedMemberView] = useState<{
|
||
initialTab?: MemberDetailTab;
|
||
initialActivityFilter?: MemberActivityFilter;
|
||
} | null>(null);
|
||
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(() =>
|
||
getTeamPendingRepliesState(teamName)
|
||
);
|
||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||
open: false,
|
||
defaultSubject: '',
|
||
defaultDescription: '',
|
||
defaultOwner: '',
|
||
});
|
||
const [creatingTask, setCreatingTask] = useState(false);
|
||
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
||
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
||
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||
const [launchDialogState, setLaunchDialogState] = useState<{
|
||
open: boolean;
|
||
mode: TeamLaunchDialogMode;
|
||
}>({
|
||
open: false,
|
||
mode: 'launch',
|
||
});
|
||
const [editorOpen, setEditorOpen] = useState(false);
|
||
const [graphOpen, setGraphOpen] = useState(false);
|
||
const contentRef = useRef<HTMLDivElement>(null);
|
||
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||
null
|
||
);
|
||
const [floatingComposerHeight, setFloatingComposerHeight] = useState(0);
|
||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||
const wasProvisioningRef = useRef(false);
|
||
const handleFloatingComposerHeightChange = useCallback((height: number) => {
|
||
setFloatingComposerHeight((currentHeight) =>
|
||
currentHeight === height ? currentHeight : height
|
||
);
|
||
}, []);
|
||
const handleOpenGraphTab = useCallback(() => {
|
||
const state = useStore.getState();
|
||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||
state.openTab({
|
||
type: 'graph',
|
||
label: `${displayName} Graph`,
|
||
teamName,
|
||
});
|
||
}, [teamName]);
|
||
const visualizeButtonStyle = useMemo<CSSProperties>(
|
||
() =>
|
||
isLight
|
||
? {
|
||
background:
|
||
'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)',
|
||
borderColor: 'rgba(59,130,246,0.30)',
|
||
color: '#0f172a',
|
||
boxShadow: '0 10px 24px rgba(59,130,246,0.12)',
|
||
}
|
||
: {
|
||
background:
|
||
'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)',
|
||
borderColor: 'rgba(56,189,248,0.34)',
|
||
color: 'rgba(236,253,255,0.96)',
|
||
boxShadow: '0 12px 28px rgba(8,145,178,0.22)',
|
||
},
|
||
[isLight]
|
||
);
|
||
|
||
// Set inert on background content when editor/graph overlay is open (a11y focus trap)
|
||
useEffect(() => {
|
||
const el = contentRef.current;
|
||
if (!el) return;
|
||
if (editorOpen || graphOpen) {
|
||
el.setAttribute('inert', '');
|
||
} else {
|
||
el.removeAttribute('inert');
|
||
}
|
||
}, [editorOpen, graphOpen]);
|
||
|
||
// Listen for Cmd+Shift+G keyboard shortcut — opens graph tab
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
const detail = (e as CustomEvent).detail;
|
||
if (detail?.teamName === teamName) {
|
||
handleOpenGraphTab();
|
||
}
|
||
};
|
||
window.addEventListener('toggle-team-graph', handler);
|
||
return () => window.removeEventListener('toggle-team-graph', handler);
|
||
}, [handleOpenGraphTab, teamName]);
|
||
|
||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||
const [stoppingTeam, setStoppingTeam] = useState(false);
|
||
const [trashOpen, setTrashOpen] = useState(false);
|
||
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
||
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(undefined);
|
||
const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>(
|
||
undefined
|
||
);
|
||
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
|
||
undefined
|
||
);
|
||
const [reviewDialogState, setReviewDialogState] = useState<{
|
||
open: boolean;
|
||
mode: 'agent' | 'task';
|
||
memberName?: string;
|
||
taskId?: string;
|
||
initialFilePath?: string;
|
||
taskChangeRequestOptions?: TaskChangeRequestOptions;
|
||
}>({ open: false, mode: 'task' });
|
||
|
||
// Active teams for conflict warning in LaunchTeamDialog
|
||
const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState<
|
||
{ teamName: string; displayName: string; projectPath: string }[]
|
||
>([]);
|
||
const launchDialogOpen = launchDialogState.open;
|
||
|
||
// Session loading and filtering state
|
||
const [sessions, setSessions] = useState<Session[]>([]);
|
||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
|
||
sessionId: null,
|
||
selectedOwners: new Set(),
|
||
columns: new Set(),
|
||
});
|
||
const [kanbanSort, setKanbanSort] = useState<KanbanSortState>({ field: 'updatedAt' });
|
||
|
||
const {
|
||
data,
|
||
members,
|
||
loading,
|
||
error,
|
||
projects,
|
||
repositoryGroups,
|
||
initTabUIState,
|
||
selectTeam,
|
||
updateKanban,
|
||
updateKanbanColumnOrder,
|
||
updateTaskStatus,
|
||
updateTaskOwner,
|
||
sendTeamMessage,
|
||
requestReview,
|
||
createTeamTask,
|
||
startTaskByUser,
|
||
deleteTeam,
|
||
openTeamsTab,
|
||
closeTab,
|
||
sendingMessage,
|
||
sendMessageError,
|
||
sendMessageWarning,
|
||
sendMessageDebugDetails,
|
||
lastSendMessageResult,
|
||
reviewActionError,
|
||
addMember,
|
||
restartMember,
|
||
skipMemberForLaunch,
|
||
removeMember,
|
||
restoreMember,
|
||
updateMemberRole,
|
||
launchTeam,
|
||
provisioningError,
|
||
clearProvisioningError,
|
||
isTeamProvisioning,
|
||
refreshTeamData,
|
||
refreshTeamMessagesHead,
|
||
refreshMemberActivityMeta,
|
||
syncTeamPendingReplyRefresh,
|
||
kanbanFilterQuery,
|
||
clearKanbanFilter,
|
||
softDeleteTask,
|
||
restoreTask,
|
||
fetchDeletedTasks,
|
||
deletedTasks,
|
||
activeTaskLogActivity,
|
||
launchParams,
|
||
messagesPanelMode,
|
||
messagesPanelWidth,
|
||
sidebarLogsHeight,
|
||
setMessagesPanelMode,
|
||
setMessagesPanelWidth,
|
||
setSidebarLogsHeight,
|
||
selectReviewFile,
|
||
pendingReviewRequest,
|
||
setPendingReviewRequest,
|
||
summaryKnownTeammateCount,
|
||
teamSummaryColor,
|
||
teamSummaryDisplayName,
|
||
} = useStore(
|
||
useShallow((s) => ({
|
||
projects: s.projects,
|
||
repositoryGroups: s.repositoryGroups,
|
||
initTabUIState: s.initTabUIState,
|
||
selectTeam: s.selectTeam,
|
||
updateKanban: s.updateKanban,
|
||
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
|
||
updateTaskStatus: s.updateTaskStatus,
|
||
updateTaskOwner: s.updateTaskOwner,
|
||
sendTeamMessage: s.sendTeamMessage,
|
||
requestReview: s.requestReview,
|
||
createTeamTask: s.createTeamTask,
|
||
startTaskByUser: s.startTaskByUser,
|
||
deleteTeam: s.deleteTeam,
|
||
openTeamsTab: s.openTeamsTab,
|
||
closeTab: s.closeTab,
|
||
sendingMessage: s.sendingMessage,
|
||
sendMessageError: s.sendMessageError,
|
||
sendMessageWarning: s.sendMessageWarning,
|
||
sendMessageDebugDetails: s.sendMessageDebugDetails,
|
||
lastSendMessageResult: s.lastSendMessageResult,
|
||
reviewActionError: s.reviewActionError,
|
||
addMember: s.addMember,
|
||
restartMember: s.restartMember,
|
||
skipMemberForLaunch: s.skipMemberForLaunch,
|
||
removeMember: s.removeMember,
|
||
restoreMember: s.restoreMember,
|
||
updateMemberRole: s.updateMemberRole,
|
||
launchTeam: s.launchTeam,
|
||
provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null,
|
||
clearProvisioningError: s.clearProvisioningError,
|
||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
||
members: selectResolvedMembersForTeamName(s, teamName),
|
||
summaryKnownTeammateCount: teamName
|
||
? getSummaryKnownTeammateCount(s.teamByName[teamName])
|
||
: 0,
|
||
teamSummaryColor: teamName ? s.teamByName[teamName]?.color : undefined,
|
||
teamSummaryDisplayName: teamName ? s.teamByName[teamName]?.displayName : undefined,
|
||
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
||
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
||
refreshTeamData: s.refreshTeamData,
|
||
refreshTeamMessagesHead: s.refreshTeamMessagesHead,
|
||
refreshMemberActivityMeta: s.refreshMemberActivityMeta,
|
||
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||
kanbanFilterQuery: s.kanbanFilterQuery,
|
||
clearKanbanFilter: s.clearKanbanFilter,
|
||
softDeleteTask: s.softDeleteTask,
|
||
restoreTask: s.restoreTask,
|
||
fetchDeletedTasks: s.fetchDeletedTasks,
|
||
deletedTasks: s.deletedTasks,
|
||
activeTaskLogActivity: teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
|
||
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
|
||
messagesPanelMode: s.messagesPanelMode,
|
||
messagesPanelWidth: s.messagesPanelWidth,
|
||
sidebarLogsHeight: s.sidebarLogsHeight,
|
||
setMessagesPanelMode: s.setMessagesPanelMode,
|
||
setMessagesPanelWidth: s.setMessagesPanelWidth,
|
||
setSidebarLogsHeight: s.setSidebarLogsHeight,
|
||
selectReviewFile: s.selectReviewFile,
|
||
pendingReviewRequest: s.pendingReviewRequest,
|
||
setPendingReviewRequest: s.setPendingReviewRequest,
|
||
}))
|
||
);
|
||
|
||
const tabId = useOptionalTabId();
|
||
const isThisTabActive = isActive;
|
||
const wasInteractiveRef = useRef(false);
|
||
const memberRosterHydrationRetryRef = useRef<string | null>(null);
|
||
const loadingHeaderColorSet = useMemo(
|
||
() =>
|
||
teamSummaryColor
|
||
? getTeamColorSet(teamSummaryColor)
|
||
: nameColorSet(teamSummaryDisplayName || teamName),
|
||
[teamName, teamSummaryColor, teamSummaryDisplayName]
|
||
);
|
||
|
||
// Messages panel resize
|
||
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
||
useResizablePanel({
|
||
width: messagesPanelWidth,
|
||
onWidthChange: setMessagesPanelWidth,
|
||
minWidth: 280,
|
||
maxWidth: 600,
|
||
side: 'left',
|
||
});
|
||
const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({
|
||
height: sidebarLogsHeight,
|
||
onHeightChange: setSidebarLogsHeight,
|
||
minHeight: 120,
|
||
maxHeight: 520,
|
||
side: 'top',
|
||
});
|
||
|
||
const changeMessagesPanelMode = useCallback(
|
||
(mode: TeamMessagesPanelMode) => {
|
||
setMessagesPanelMode(mode);
|
||
},
|
||
[setMessagesPanelMode]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (tabId) {
|
||
initTabUIState(tabId);
|
||
}
|
||
}, [tabId, initTabUIState]);
|
||
|
||
useEffect(() => {
|
||
setPendingRepliesByMember(getTeamPendingRepliesState(teamName));
|
||
}, [teamName]);
|
||
|
||
useEffect(() => {
|
||
setTeamPendingRepliesState(teamName, pendingRepliesByMember);
|
||
}, [pendingRepliesByMember, teamName]);
|
||
|
||
useEffect(() => {
|
||
const wasProvisioning = wasProvisioningRef.current;
|
||
wasProvisioningRef.current = isTeamProvisioning;
|
||
if (!isThisTabActive) return;
|
||
if (!wasProvisioning && isTeamProvisioning) {
|
||
provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}, [isTeamProvisioning, isThisTabActive]);
|
||
|
||
const [kanbanSearch, setKanbanSearch] = useState('');
|
||
|
||
// Open editor overlay when a file reveal is requested (e.g. from chip click)
|
||
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
|
||
useEffect(() => {
|
||
if (!isThisTabActive) return;
|
||
if (pendingRevealFile && data?.config.projectPath) {
|
||
setEditorOpen(true);
|
||
}
|
||
}, [isThisTabActive, pendingRevealFile, data?.config.projectPath]);
|
||
|
||
useEffect(() => {
|
||
if (!isThisTabActive || !teamName) {
|
||
return;
|
||
}
|
||
void selectTeam(teamName);
|
||
void fetchDeletedTasks(teamName);
|
||
}, [isThisTabActive, teamName, selectTeam, fetchDeletedTasks]);
|
||
|
||
// Re-trigger selectTeam when this visible tab becomes active and store data is stale.
|
||
const storedTeamName = data?.teamName;
|
||
useEffect(() => {
|
||
if (!isThisTabActive || !teamName || loading) return;
|
||
if (storedTeamName != null && storedTeamName !== teamName) {
|
||
void selectTeam(teamName);
|
||
}
|
||
}, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]);
|
||
|
||
useEffect(() => {
|
||
const isInteractive = isThisTabActive && isPaneFocused;
|
||
const justBecameInteractive = isInteractive && !wasInteractiveRef.current;
|
||
wasInteractiveRef.current = isInteractive;
|
||
if (!justBecameInteractive || !teamName) {
|
||
return;
|
||
}
|
||
|
||
void (async () => {
|
||
try {
|
||
const headResult = await refreshTeamMessagesHead(teamName);
|
||
if (headResult.feedChanged) {
|
||
await refreshMemberActivityMeta(teamName);
|
||
}
|
||
} catch {
|
||
// Best-effort refresh on tab focus.
|
||
}
|
||
})();
|
||
}, [
|
||
isPaneFocused,
|
||
isThisTabActive,
|
||
refreshMemberActivityMeta,
|
||
refreshTeamMessagesHead,
|
||
teamName,
|
||
]);
|
||
|
||
// Fetch active teams when launch dialog opens (for conflict warning)
|
||
useEffect(() => {
|
||
if (!isThisTabActive || !launchDialogOpen) return;
|
||
let cancelled = false;
|
||
const teamsSnapshot = useStore.getState().teams;
|
||
void (async () => {
|
||
try {
|
||
const aliveList = await api.teams.aliveList();
|
||
if (cancelled) return;
|
||
const aliveSet = new Set(aliveList);
|
||
const refs = teamsSnapshot
|
||
.filter((t) => aliveSet.has(t.teamName) && t.projectPath)
|
||
.map((t) => ({
|
||
teamName: t.teamName,
|
||
displayName: t.displayName,
|
||
projectPath: t.projectPath!,
|
||
}));
|
||
setActiveTeamsForLaunch(refs);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [isThisTabActive, launchDialogOpen]);
|
||
|
||
useEffect(() => {
|
||
if (kanbanFilterQuery) {
|
||
setKanbanSearch(kanbanFilterQuery);
|
||
clearKanbanFilter();
|
||
}
|
||
}, [kanbanFilterQuery, clearKanbanFilter]);
|
||
|
||
// Load sessions for the team's project
|
||
const projectId = useMemo(
|
||
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
|
||
[projects, repositoryGroups, data?.config.projectPath]
|
||
);
|
||
|
||
const leadSessionId = data?.config.leadSessionId ?? null;
|
||
const pendingReplyRefreshSourceId = useId();
|
||
const sessionHistoryKey = useMemo(
|
||
() => (data?.config.sessionHistory ?? []).join('|'),
|
||
[data?.config.sessionHistory]
|
||
);
|
||
|
||
// Keep team message state fresh while we are explicitly waiting for a reply.
|
||
// This stays enabled even for hidden mounted tabs, because the waiting state
|
||
// is renderer-local and should keep its lightweight polling until resolved.
|
||
useEffect(() => {
|
||
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||
syncTeamPendingReplyRefresh(
|
||
teamName,
|
||
pendingReplyRefreshSourceId,
|
||
Boolean(data?.isAlive) && hasPendingReplies,
|
||
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||
);
|
||
|
||
return () => {
|
||
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
|
||
};
|
||
}, [
|
||
data?.isAlive,
|
||
pendingRepliesByMember,
|
||
pendingReplyRefreshSourceId,
|
||
syncTeamPendingReplyRefresh,
|
||
teamName,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!isThisTabActive || !projectId) return;
|
||
|
||
let cancelled = false;
|
||
setSessionsLoading(true);
|
||
setSessionsError(null);
|
||
|
||
void (async () => {
|
||
try {
|
||
const result = await loadTeamSessionMetadata(api, projectId, {
|
||
leadSessionId: data?.config.leadSessionId ?? null,
|
||
sessionHistory: data?.config.sessionHistory ?? [],
|
||
});
|
||
if (!cancelled) {
|
||
setSessions(result);
|
||
}
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions');
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setSessionsLoading(false);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [data?.config.leadSessionId, isThisTabActive, projectId, sessionHistoryKey]);
|
||
|
||
// Live git branch tracking for the lead project and member worktrees
|
||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||
const leadProjectPath = useMemo(() => {
|
||
const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||
}, [members, teamProjectPath]);
|
||
const branchSyncPaths = useMemo(() => {
|
||
const uniquePaths = new Map<string, string>();
|
||
const addPath = (candidate: string | null | undefined): void => {
|
||
const trimmed = candidate?.trim();
|
||
if (!trimmed) return;
|
||
const key = normalizePath(trimmed);
|
||
if (!key || uniquePaths.has(key)) return;
|
||
uniquePaths.set(key, trimmed);
|
||
};
|
||
|
||
addPath(leadProjectPath);
|
||
for (const member of members) {
|
||
addPath(member.cwd);
|
||
}
|
||
|
||
return Array.from(uniquePaths.values());
|
||
}, [members, leadProjectPath]);
|
||
const activeBranchSyncPaths = useMemo(
|
||
() => (isThisTabActive ? branchSyncPaths : []),
|
||
[branchSyncPaths, isThisTabActive]
|
||
);
|
||
useBranchSync(activeBranchSyncPaths, { live: isThisTabActive });
|
||
const trackedBranches = useStore(
|
||
useShallow((s) =>
|
||
Object.fromEntries(
|
||
branchSyncPaths.map((projectPath) => {
|
||
const normalizedPath = normalizePath(projectPath);
|
||
return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const;
|
||
})
|
||
)
|
||
)
|
||
);
|
||
const leadBranch = leadProjectPath
|
||
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
|
||
: null;
|
||
const membersWithLiveBranches = useMemo(() => {
|
||
if (!data) return [];
|
||
|
||
return members.map((member) => {
|
||
const memberPath = member.cwd?.trim();
|
||
const nextGitBranch =
|
||
memberPath && !isLeadMember(member) && leadBranch !== null
|
||
? (() => {
|
||
const branch = trackedBranches[normalizePath(memberPath)] ?? null;
|
||
return branch && branch !== leadBranch ? branch : undefined;
|
||
})()
|
||
: undefined;
|
||
|
||
if (member.gitBranch === nextGitBranch) {
|
||
return member;
|
||
}
|
||
|
||
const nextMember: ResolvedTeamMember = { ...member };
|
||
if (nextGitBranch) {
|
||
nextMember.gitBranch = nextGitBranch;
|
||
} else {
|
||
delete nextMember.gitBranch;
|
||
}
|
||
return nextMember;
|
||
});
|
||
}, [leadBranch, members, trackedBranches]);
|
||
const resolvedMemberColorMap = useMemo(
|
||
() => buildMemberColorMap(membersWithLiveBranches),
|
||
[membersWithLiveBranches]
|
||
);
|
||
|
||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||
const teamSessionIds = useMemo(() => {
|
||
const sessionIds = new Set<string>();
|
||
if (data?.config.leadSessionId) {
|
||
sessionIds.add(data.config.leadSessionId);
|
||
}
|
||
if (data?.config.sessionHistory) {
|
||
for (const id of data.config.sessionHistory) {
|
||
sessionIds.add(id);
|
||
}
|
||
}
|
||
return sessionIds;
|
||
}, [data?.config.leadSessionId, data?.config.sessionHistory]);
|
||
|
||
const teamSessions = useMemo(() => {
|
||
// If no session IDs known (backward compat), show all sessions
|
||
if (teamSessionIds.size === 0) return sessions;
|
||
return sessions.filter((s) => teamSessionIds.has(s.id));
|
||
}, [sessions, teamSessionIds]);
|
||
|
||
// Auto-reset session filter if the selected session is no longer in teamSessions
|
||
useEffect(() => {
|
||
if (
|
||
kanbanFilter.sessionId !== null &&
|
||
!teamSessions.some((s) => s.id === kanbanFilter.sessionId)
|
||
) {
|
||
setKanbanFilter((prev) => ({ ...prev, sessionId: null }));
|
||
}
|
||
}, [kanbanFilter.sessionId, teamSessions]);
|
||
|
||
// Compute time-window for session filtering
|
||
const timeWindow = useMemo<TimeWindow | null>(() => {
|
||
if (kanbanFilter.sessionId === null) return null;
|
||
|
||
const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt);
|
||
const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId);
|
||
if (idx === -1) return null;
|
||
|
||
const start = sorted[idx].createdAt;
|
||
const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity;
|
||
return { start, end };
|
||
}, [kanbanFilter.sessionId, teamSessions]);
|
||
|
||
// Filter tasks by time-window and owner
|
||
const filteredTasks = useMemo(() => {
|
||
if (!data) return [];
|
||
let result = data.tasks;
|
||
|
||
// Session time-window filter
|
||
if (timeWindow) {
|
||
result = result.filter((t) => {
|
||
if (!t.createdAt) return true; // legacy tasks always included
|
||
const ts = new Date(t.createdAt).getTime();
|
||
return ts >= timeWindow.start && ts < timeWindow.end;
|
||
});
|
||
}
|
||
|
||
// Owner filter
|
||
if (kanbanFilter.selectedOwners.size > 0) {
|
||
result = result.filter((t) =>
|
||
t.owner
|
||
? kanbanFilter.selectedOwners.has(t.owner)
|
||
: kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER)
|
||
);
|
||
}
|
||
|
||
return result;
|
||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
||
|
||
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
|
||
|
||
const kanbanSearchQuery = kanbanSearch.trim();
|
||
const isKanbanSearchActive = kanbanSearchQuery.length > 0;
|
||
const kanbanDisplayTasks = useMemo(() => {
|
||
if (!kanbanSearchQuery) return filteredTasks;
|
||
return filterKanbanTasks(filteredTasks, kanbanSearchQuery);
|
||
}, [filteredTasks, kanbanSearchQuery]);
|
||
|
||
const resolvedActiveTeammateCount = useMemo(
|
||
() => activeMembers.filter((m) => !isLeadMember(m)).length,
|
||
[activeMembers]
|
||
);
|
||
const activeTeammateCount = useMemo(() => {
|
||
if (membersWithLiveBranches.some((m) => m.removedAt)) {
|
||
return resolvedActiveTeammateCount;
|
||
}
|
||
return resolvedActiveTeammateCount > 0
|
||
? resolvedActiveTeammateCount
|
||
: summaryKnownTeammateCount;
|
||
}, [membersWithLiveBranches, resolvedActiveTeammateCount, summaryKnownTeammateCount]);
|
||
|
||
const memberRosterHydrationRetryKey = useMemo(() => {
|
||
if (
|
||
!isThisTabActive ||
|
||
!teamName ||
|
||
!data ||
|
||
summaryKnownTeammateCount <= 0 ||
|
||
resolvedActiveTeammateCount > 0
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
teamName,
|
||
data.teamName,
|
||
data.members.length,
|
||
data.config.members?.length ?? 0,
|
||
data.config.sessionHistory?.join(',') ?? '',
|
||
summaryKnownTeammateCount,
|
||
loading ? 'loading' : 'settled',
|
||
isTeamProvisioning ? 'provisioning' : 'ready',
|
||
].join('|');
|
||
}, [
|
||
data,
|
||
isTeamProvisioning,
|
||
isThisTabActive,
|
||
loading,
|
||
resolvedActiveTeammateCount,
|
||
summaryKnownTeammateCount,
|
||
teamName,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!memberRosterHydrationRetryKey) {
|
||
return;
|
||
}
|
||
if (memberRosterHydrationRetryRef.current === memberRosterHydrationRetryKey) {
|
||
return;
|
||
}
|
||
|
||
const timer = window.setTimeout(() => {
|
||
const state = useStore.getState();
|
||
if (state.selectedTeamName !== teamName) {
|
||
return;
|
||
}
|
||
|
||
const currentMembers = selectResolvedMembersForTeamName(state, teamName);
|
||
const hasResolvedTeammate = currentMembers.some(
|
||
(member) => !member.removedAt && !isLeadMember(member)
|
||
);
|
||
const expectedTeammateCount = getSummaryKnownTeammateCount(state.teamByName[teamName]);
|
||
if (!hasResolvedTeammate && expectedTeammateCount > 0) {
|
||
memberRosterHydrationRetryRef.current = memberRosterHydrationRetryKey;
|
||
void refreshTeamData(teamName, { withDedup: false });
|
||
}
|
||
}, MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS);
|
||
|
||
return () => window.clearTimeout(timer);
|
||
}, [memberRosterHydrationRetryKey, refreshTeamData, teamName]);
|
||
const leadProviderId = useMemo<TeamProviderId | undefined>(() => {
|
||
const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId;
|
||
if (activeLeadProviderId) return activeLeadProviderId;
|
||
const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId;
|
||
if (configuredLeadProviderId) return configuredLeadProviderId;
|
||
return launchParams?.providerId;
|
||
}, [activeMembers, data?.config.members, launchParams?.providerId]);
|
||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||
const taskMapRef = useRef(taskMap);
|
||
taskMapRef.current = taskMap;
|
||
|
||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||
|
||
const openCreateTaskDialog = useCallback(
|
||
(subject = '', description = '', owner = '', startImmediately?: boolean): void => {
|
||
setCreateTaskDialog({
|
||
open: true,
|
||
defaultSubject: subject,
|
||
defaultDescription: description,
|
||
defaultOwner: owner,
|
||
defaultStartImmediately: startImmediately,
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
const closeCreateTaskDialog = useCallback((): void => {
|
||
setCreateTaskDialog({
|
||
open: false,
|
||
defaultSubject: '',
|
||
defaultDescription: '',
|
||
defaultOwner: '',
|
||
defaultStartImmediately: undefined,
|
||
});
|
||
}, []);
|
||
|
||
const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => {
|
||
openCreateTaskDialog(subject, description);
|
||
}, []);
|
||
|
||
const handleReplyToMessage = useCallback((message: { from: string; text: string }) => {
|
||
setSendDialogRecipient(message.from);
|
||
setSendDialogDefaultText(undefined);
|
||
setSendDialogDefaultChip(undefined);
|
||
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
|
||
setSendDialogOpen(true);
|
||
}, []);
|
||
|
||
const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => {
|
||
setLaunchDialogState({ open: true, mode });
|
||
}, []);
|
||
|
||
const closeLaunchDialog = useCallback(() => {
|
||
setLaunchDialogState((prev) => ({ ...prev, open: false }));
|
||
}, []);
|
||
|
||
const handleRestartTeam = useCallback(() => {
|
||
openLaunchDialog('relaunch');
|
||
}, [openLaunchDialog]);
|
||
|
||
const handleLaunchDialogSubmit = useCallback(
|
||
async (request: TeamLaunchRequest): Promise<void> => {
|
||
await launchTeam(request);
|
||
},
|
||
[launchTeam]
|
||
);
|
||
|
||
const handleRelaunchDialogSubmit = useCallback(
|
||
async (
|
||
request: TeamLaunchRequest,
|
||
nextMembers: TeamCreateRequest['members']
|
||
): Promise<void> => {
|
||
await executeTeamRelaunch({
|
||
teamName,
|
||
isTeamAlive: data?.isAlive === true,
|
||
request,
|
||
members: nextMembers,
|
||
stopTeam: (nextTeamName) => api.teams.stop(nextTeamName),
|
||
replaceMembers: (nextTeamName, nextRequest) =>
|
||
api.teams.replaceMembers(nextTeamName, nextRequest),
|
||
launchTeam,
|
||
});
|
||
},
|
||
[data?.isAlive, launchTeam, teamName]
|
||
);
|
||
|
||
const handleChangeLeadRuntime = useCallback(() => {
|
||
setEditDialogOpen(false);
|
||
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
|
||
}, [data?.isAlive, isTeamProvisioning, openLaunchDialog]);
|
||
|
||
const handleRestartMember = useCallback(
|
||
async (memberName: string): Promise<void> => {
|
||
await restartMember(teamName, memberName);
|
||
},
|
||
[restartMember, teamName]
|
||
);
|
||
|
||
const handleSkipMemberForLaunch = useCallback(
|
||
async (memberName: string): Promise<void> => {
|
||
await skipMemberForLaunch(teamName, memberName);
|
||
},
|
||
[skipMemberForLaunch, teamName]
|
||
);
|
||
|
||
const handleRestoreMember = useCallback(
|
||
async (memberName: string): Promise<void> => {
|
||
await restoreMember(teamName, memberName);
|
||
},
|
||
[restoreMember, teamName]
|
||
);
|
||
|
||
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
|
||
setSelectedMember(member);
|
||
setSelectedMemberView(null);
|
||
}, []);
|
||
|
||
const closeSelectedMemberDialog = useCallback(() => {
|
||
setSelectedMember(null);
|
||
setSelectedMemberView(null);
|
||
}, []);
|
||
|
||
const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => {
|
||
setSendDialogRecipient(member.name);
|
||
setSendDialogDefaultText(undefined);
|
||
setSendDialogDefaultChip(undefined);
|
||
setReplyQuote(undefined);
|
||
setSendDialogOpen(true);
|
||
}, []);
|
||
|
||
const handleAssignTaskToMember = useCallback(
|
||
(member: ResolvedTeamMember) => {
|
||
openCreateTaskDialog('', '', member.name);
|
||
},
|
||
[openCreateTaskDialog]
|
||
);
|
||
|
||
const handleOpenTaskById = useCallback((taskId: string) => {
|
||
const task = taskMapRef.current.get(taskId);
|
||
if (task) {
|
||
setSelectedTask(task);
|
||
}
|
||
}, []);
|
||
|
||
const handleOpenMessagePanelTask = useCallback(
|
||
(task: TeamTaskWithKanban) => {
|
||
handleOpenTaskById(task.id);
|
||
},
|
||
[handleOpenTaskById]
|
||
);
|
||
|
||
const handleTaskIdClick = useCallback(
|
||
(taskId: string) => {
|
||
const task =
|
||
taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId);
|
||
if (task) setSelectedTask(task);
|
||
},
|
||
[taskMap, data?.tasks]
|
||
);
|
||
|
||
const handleEditorAction = useCallback(
|
||
(action: EditorSelectionAction) => {
|
||
const chip = createChipFromSelection(action, []) ?? undefined;
|
||
if (action.type === 'sendMessage') {
|
||
setSendDialogDefaultText(chip ? undefined : action.formattedContext);
|
||
setSendDialogDefaultChip(chip);
|
||
setSendDialogRecipient(undefined);
|
||
setReplyQuote(undefined);
|
||
setSendDialogOpen(true);
|
||
} else if (action.type === 'createTask') {
|
||
if (chip) {
|
||
setCreateTaskDialog({
|
||
open: true,
|
||
defaultSubject: '',
|
||
defaultDescription: '',
|
||
defaultOwner: '',
|
||
defaultStartImmediately: undefined,
|
||
defaultChip: chip,
|
||
});
|
||
} else {
|
||
openCreateTaskDialog('', action.formattedContext);
|
||
}
|
||
}
|
||
},
|
||
|
||
[]
|
||
);
|
||
|
||
const handleStopTeam = useCallback(async (): Promise<void> => {
|
||
setStoppingTeam(true);
|
||
try {
|
||
await api.teams.stop(teamName);
|
||
// Backend sends 'disconnected' progress which triggers store refresh,
|
||
// but refresh here too as a safety net (e.g. if progress event is missed).
|
||
await refreshTeamData(teamName);
|
||
} catch (err) {
|
||
console.error('Failed to stop team:', err);
|
||
} finally {
|
||
setStoppingTeam(false);
|
||
}
|
||
}, [teamName, refreshTeamData]);
|
||
|
||
// Pick up pending review request from GlobalTaskDetailDialog
|
||
useEffect(() => {
|
||
if (!isThisTabActive) return;
|
||
if (!pendingReviewRequest) return;
|
||
setReviewDialogState({
|
||
open: true,
|
||
mode: 'task',
|
||
taskId: pendingReviewRequest.taskId,
|
||
initialFilePath: pendingReviewRequest.filePath,
|
||
taskChangeRequestOptions: pendingReviewRequest.requestOptions,
|
||
});
|
||
if (pendingReviewRequest.filePath) {
|
||
selectReviewFile(pendingReviewRequest.filePath);
|
||
}
|
||
setPendingReviewRequest(null);
|
||
}, [isThisTabActive, pendingReviewRequest, selectReviewFile, setPendingReviewRequest]);
|
||
|
||
const pendingTeamSectionFocus = useStore((s) => s.pendingTeamSectionFocus);
|
||
const clearTeamSectionFocus = useStore((s) => s.clearTeamSectionFocus);
|
||
useEffect(() => {
|
||
if (!isThisTabActive) return;
|
||
if (pendingTeamSectionFocus?.teamName !== teamName) return;
|
||
|
||
const sectionId =
|
||
pendingTeamSectionFocus.section === 'members'
|
||
? 'team'
|
||
: pendingTeamSectionFocus.section === 'tasks'
|
||
? 'kanban'
|
||
: pendingTeamSectionFocus.section;
|
||
|
||
if (sectionId === 'overview') {
|
||
contentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
clearTeamSectionFocus();
|
||
return;
|
||
}
|
||
|
||
const section = contentRef.current?.querySelector<HTMLElement>(
|
||
`[data-section-id="${sectionId}"]`
|
||
);
|
||
if (!section) return;
|
||
section.dispatchEvent(new CustomEvent('team-section-navigate'));
|
||
clearTeamSectionFocus();
|
||
}, [pendingTeamSectionFocus, clearTeamSectionFocus, isThisTabActive, teamName, data]);
|
||
|
||
// Pick up pending member profile request from MemberHoverCard
|
||
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
||
useEffect(() => {
|
||
if (!isThisTabActive) return;
|
||
if (!pendingMemberProfile || !data) return;
|
||
if (pendingMemberProfile.teamName && pendingMemberProfile.teamName !== teamName) return;
|
||
|
||
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile.memberName);
|
||
if (member) {
|
||
setSelectedMember(member);
|
||
setSelectedMemberView({
|
||
initialTab:
|
||
pendingMemberProfile.focus === 'logs'
|
||
? 'logs'
|
||
: pendingMemberProfile.focus === 'messages'
|
||
? 'activity'
|
||
: undefined,
|
||
});
|
||
}
|
||
useStore.getState().closeMemberProfile();
|
||
}, [isThisTabActive, pendingMemberProfile, membersWithLiveBranches, teamName, data]);
|
||
|
||
const handleDeleteTask = useCallback(
|
||
(taskId: string) => {
|
||
void (async () => {
|
||
const confirmed = await confirm({
|
||
title: t('tasks.deleteConfirm.title'),
|
||
message: t('tasks.deleteConfirm.message', { taskId: deriveTaskDisplayId(taskId) }),
|
||
confirmLabel: t('tasks.deleteConfirm.confirmLabel'),
|
||
cancelLabel: t('tasks.deleteConfirm.cancelLabel'),
|
||
variant: 'danger',
|
||
});
|
||
if (confirmed) {
|
||
try {
|
||
await softDeleteTask(teamName, taskId);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
}
|
||
})();
|
||
},
|
||
[teamName, softDeleteTask, t]
|
||
);
|
||
|
||
const handleViewChanges = useCallback(
|
||
(taskId: string) => {
|
||
const task = taskMap.get(taskId);
|
||
setReviewDialogState({
|
||
open: true,
|
||
mode: 'task',
|
||
taskId,
|
||
taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {},
|
||
});
|
||
},
|
||
[taskMap]
|
||
);
|
||
|
||
const handleViewChangesForFile = useCallback(
|
||
(taskId: string, filePath?: string) => {
|
||
const task = taskMap.get(taskId);
|
||
setReviewDialogState({
|
||
open: true,
|
||
mode: 'task',
|
||
taskId,
|
||
initialFilePath: filePath,
|
||
taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {},
|
||
});
|
||
if (filePath) {
|
||
selectReviewFile(filePath);
|
||
}
|
||
},
|
||
[selectReviewFile, taskMap]
|
||
);
|
||
|
||
const handleDeleteTeam = useCallback((): void => {
|
||
setDeleteConfirmOpen(true);
|
||
}, []);
|
||
|
||
const confirmDeleteTeam = useCallback((): void => {
|
||
setDeleteConfirmOpen(false);
|
||
void (async () => {
|
||
try {
|
||
await deleteTeam(teamName);
|
||
if (tabId) closeTab(tabId);
|
||
openTeamsTab();
|
||
} catch {
|
||
// error is shown via store
|
||
}
|
||
})();
|
||
}, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]);
|
||
|
||
const handleCreateTask = (
|
||
subject: string,
|
||
description: string,
|
||
owner?: string,
|
||
blockedBy?: string[],
|
||
related?: string[],
|
||
prompt?: string,
|
||
startImmediately?: boolean,
|
||
descriptionTaskRefs?: TaskRef[],
|
||
promptTaskRefs?: TaskRef[]
|
||
): void => {
|
||
setCreatingTask(true);
|
||
void (async () => {
|
||
try {
|
||
await createTeamTask(teamName, {
|
||
subject,
|
||
description: description || undefined,
|
||
owner,
|
||
blockedBy,
|
||
related,
|
||
prompt,
|
||
descriptionTaskRefs,
|
||
promptTaskRefs,
|
||
startImmediately,
|
||
});
|
||
|
||
if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) {
|
||
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
|
||
try {
|
||
await api.teams.processSend(teamName, msg);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
|
||
closeCreateTaskDialog();
|
||
} catch {
|
||
// error shown via store
|
||
} finally {
|
||
setCreatingTask(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks);
|
||
|
||
const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>(
|
||
() => ({
|
||
teamName,
|
||
onPositionChange: changeMessagesPanelMode,
|
||
mountPoint: messagesPanelMountPoint,
|
||
members: activeMembers,
|
||
tasks: messagesPanelTasks,
|
||
isTeamAlive: data?.isAlive,
|
||
timeWindow,
|
||
currentLeadSessionId: data?.config.leadSessionId,
|
||
pendingRepliesByMember,
|
||
onPendingReplyChange: setPendingRepliesByMember,
|
||
onMemberClick: handleSelectMember,
|
||
onTaskClick: handleOpenMessagePanelTask,
|
||
onCreateTaskFromMessage: handleCreateTaskFromMessage,
|
||
onReplyToMessage: handleReplyToMessage,
|
||
onRestartTeam: handleRestartTeam,
|
||
onTaskIdClick: handleTaskIdClick,
|
||
onFloatingComposerHeightChange: handleFloatingComposerHeightChange,
|
||
inlineScrollContainerRef: contentRef,
|
||
}),
|
||
[
|
||
activeMembers,
|
||
data?.config.leadSessionId,
|
||
data?.isAlive,
|
||
handleCreateTaskFromMessage,
|
||
handleOpenMessagePanelTask,
|
||
handleReplyToMessage,
|
||
handleRestartTeam,
|
||
handleSelectMember,
|
||
handleTaskIdClick,
|
||
handleFloatingComposerHeightChange,
|
||
messagesPanelTasks,
|
||
messagesPanelMountPoint,
|
||
pendingRepliesByMember,
|
||
teamName,
|
||
timeWindow,
|
||
changeMessagesPanelMode,
|
||
]
|
||
);
|
||
|
||
if (!teamName) {
|
||
return (
|
||
<div className="flex size-full items-center justify-center p-6 text-sm text-red-400">
|
||
{t('detail.invalidTab')}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const spawnStatusWatcher = (
|
||
<TeamSpawnStatusWatcher
|
||
teamName={teamName}
|
||
isTeamProvisioning={isTeamProvisioning}
|
||
isTeamAlive={data?.isAlive}
|
||
isThisTabActive={isThisTabActive}
|
||
/>
|
||
);
|
||
const teamAgentRuntimeWatcher = (
|
||
<TeamAgentRuntimeWatcher
|
||
teamName={teamName}
|
||
isTeamProvisioning={isTeamProvisioning}
|
||
isTeamAlive={data?.isAlive}
|
||
isThisTabActive={isThisTabActive}
|
||
/>
|
||
);
|
||
const renderBody = (): React.JSX.Element => {
|
||
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
||
return (
|
||
<TeamLoadingSkeleton
|
||
teamName={teamName}
|
||
isActive={isThisTabActive}
|
||
isFocused={isPaneFocused}
|
||
messagesPanelMode={messagesPanelMode}
|
||
headerColorSet={loadingHeaderColorSet}
|
||
isLight={isLight}
|
||
contentRef={contentRef}
|
||
provisioningBannerRef={provisioningBannerRef}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (error === 'TEAM_DRAFT') {
|
||
const draftTeamSummary = useStore.getState().teamByName[teamName];
|
||
const draftDisplayName = draftTeamSummary?.displayName || teamName;
|
||
const draftMemberCount = draftTeamSummary?.memberCount ?? 0;
|
||
|
||
return (
|
||
<>
|
||
<div className="size-full overflow-auto p-6">
|
||
<div ref={provisioningBannerRef}>
|
||
<TeamProvisioningBanner teamName={teamName} />
|
||
</div>
|
||
<div className="flex min-h-[calc(100vh-12rem)] items-center justify-center">
|
||
<div className="max-w-md text-center">
|
||
<p className="text-sm font-medium text-text">{t('detail.draft.title')}</p>
|
||
<p className="mt-2 text-xs text-text-secondary">
|
||
{t('detail.draft.descriptionPrefix')} <strong>{draftDisplayName}</strong>{' '}
|
||
{t('detail.draft.descriptionSuffix', {
|
||
count: draftMemberCount,
|
||
member: t('detail.draft.member', { count: draftMemberCount }),
|
||
})}
|
||
</p>
|
||
<div className="mt-4 flex justify-center gap-2">
|
||
<button
|
||
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
||
onClick={() => openLaunchDialog('launch')}
|
||
>
|
||
{t('detail.actions.launch')}
|
||
</button>
|
||
<button
|
||
className="rounded-md bg-surface-raised px-4 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:text-text"
|
||
onClick={() => {
|
||
void api.teams.deleteDraft(teamName).catch(() => {});
|
||
}}
|
||
>
|
||
{t('detail.actions.delete')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{launchDialogOpen && (
|
||
<Suspense
|
||
fallback={
|
||
<LaunchTeamDialogLoadingFallback
|
||
mode={launchDialogState.mode}
|
||
teamName={teamName}
|
||
onClose={closeLaunchDialog}
|
||
/>
|
||
}
|
||
>
|
||
<LaunchTeamDialog
|
||
mode={launchDialogState.mode}
|
||
open={launchDialogOpen}
|
||
teamName={teamName}
|
||
members={[]}
|
||
defaultProjectPath={draftTeamSummary?.projectPath}
|
||
provisioningError={provisioningError}
|
||
clearProvisioningError={clearProvisioningError}
|
||
onClose={closeLaunchDialog}
|
||
onLaunch={handleLaunchDialogSubmit}
|
||
onRelaunch={handleRelaunchDialogSubmit}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex size-full items-center justify-center p-6">
|
||
<div className="text-center">
|
||
<p className="text-sm font-medium text-red-400">{t('detail.loadFailed')}</p>
|
||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{error}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!data) {
|
||
return (
|
||
<div className="size-full overflow-auto p-4">
|
||
<div ref={provisioningBannerRef}>
|
||
<TeamProvisioningBanner teamName={teamName} />
|
||
</div>
|
||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-[var(--color-text-muted)]">
|
||
{t('detail.waitingForProvisioning')}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const headerColorSet = data.config.color
|
||
? getTeamColorSet(data.config.color)
|
||
: nameColorSet(data.config.name);
|
||
const shouldReserveFloatingComposerScrollSpace =
|
||
messagesPanelMode === 'floating-composer' && isThisTabActive && isPaneFocused && !graphOpen;
|
||
const floatingComposerScrollReserve = shouldReserveFloatingComposerScrollSpace
|
||
? FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX + floatingComposerHeight
|
||
: undefined;
|
||
|
||
return (
|
||
<>
|
||
<div className="flex size-full overflow-hidden">
|
||
<LeadLoadBridge
|
||
teamName={teamName}
|
||
tabId={tabId}
|
||
projectId={projectId}
|
||
leadSessionId={leadSessionId}
|
||
leadProviderId={leadProviderId}
|
||
fallbackProjectRoot={data.config.projectPath}
|
||
isThisTabActive={isThisTabActive}
|
||
/>
|
||
|
||
{/* Messages sidebar (left, after context panel) */}
|
||
<TeamSidebarHost
|
||
teamName={teamName}
|
||
surface="team"
|
||
isActive={isThisTabActive}
|
||
isFocused={isPaneFocused}
|
||
>
|
||
<TeamSidebarPortalSource
|
||
teamName={teamName}
|
||
isActive={isThisTabActive}
|
||
isFocused={isPaneFocused}
|
||
>
|
||
<TeamSidebarRailBridge
|
||
teamName={teamName}
|
||
messagesPanelProps={sharedMessagesPanelProps}
|
||
isResizing={isMessagesPanelResizing}
|
||
onResizeMouseDown={messagesPanelHandleProps.onMouseDown}
|
||
logsHeight={sidebarLogsHeight}
|
||
isLogsResizing={isLogsPanelResizing}
|
||
onLogsResizeMouseDown={logsPanelHandleProps.onMouseDown}
|
||
/>
|
||
</TeamSidebarPortalSource>
|
||
</TeamSidebarHost>
|
||
|
||
<div className="relative min-h-0 min-w-0 flex-1">
|
||
<div
|
||
ref={contentRef}
|
||
className="size-full min-w-0 overflow-y-auto overflow-x-hidden p-4"
|
||
style={{ paddingBottom: floatingComposerScrollReserve }}
|
||
data-team-name={teamName}
|
||
>
|
||
<div className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] px-4 py-3">
|
||
{headerColorSet ? (
|
||
<div
|
||
className="pointer-events-none absolute inset-0 z-0"
|
||
style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className={cn(
|
||
'flex items-start justify-between gap-2',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-base font-semibold text-[var(--color-text)]">
|
||
{data.config.name}
|
||
</h2>
|
||
{data.isAlive && (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||
<span className="size-1.5 rounded-full bg-emerald-400" />
|
||
{t('detail.status.running')}
|
||
</span>
|
||
)}
|
||
{!data.isAlive && isTeamProvisioning && (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||
<span className="size-1.5 animate-pulse rounded-full bg-yellow-400" />
|
||
{t('detail.status.launching')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
{data.isAlive && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||
disabled={stoppingTeam}
|
||
onClick={() => void handleStopTeam()}
|
||
>
|
||
<Square size={12} className={stoppingTeam ? 'animate-pulse' : ''} />
|
||
{t('detail.actions.stop')}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">
|
||
{t('detail.tooltips.stopTeam')}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||
disabled={isTeamProvisioning}
|
||
onClick={() => setEditDialogOpen(true)}
|
||
>
|
||
<Pencil size={12} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">
|
||
{isTeamProvisioning
|
||
? t('detail.tooltips.editUnavailableProvisioning')
|
||
: t('detail.tooltips.editTeam')}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||
onClick={handleDeleteTeam}
|
||
>
|
||
<Trash2 size={12} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">
|
||
{t('detail.tooltips.deleteTeam')}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
{data.config.description && (
|
||
<p
|
||
className={cn(
|
||
'min-w-0 truncate text-xs text-[var(--color-text-muted)]',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
{data.config.description}
|
||
</p>
|
||
)}
|
||
<div
|
||
className={cn(
|
||
'mt-1 flex items-start justify-between gap-3',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5">
|
||
{data.config.projectPath && (
|
||
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]">
|
||
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="max-w-60 truncate font-mono">
|
||
{data.config.projectPath
|
||
.replace(/\\/g, '/')
|
||
.split('/')
|
||
.filter(Boolean)
|
||
.pop() ?? data.config.projectPath}
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">
|
||
<span className="font-mono text-xs">
|
||
{formatProjectPath(data.config.projectPath)}
|
||
</span>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
onClick={() => setEditorOpen(true)}
|
||
className="ml-1 flex items-center gap-0.5 rounded border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||
>
|
||
<Code size={10} className="shrink-0" /> {t('detail.actions.editCode')}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{t('detail.tooltips.openBuiltInEditor')}</TooltipContent>
|
||
</Tooltip>
|
||
</span>
|
||
)}
|
||
{leadBranch && (
|
||
<span
|
||
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
|
||
title={leadBranch}
|
||
>
|
||
<GitBranch size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||
<span className="max-w-32 truncate">{leadBranch}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className={cn(
|
||
'-mt-2 h-8 shrink-0 self-start rounded-full border px-3.5 text-xs font-semibold tracking-[0.02em] transition-all',
|
||
'hover:-translate-y-0.5 hover:brightness-[1.03] active:translate-y-0 active:brightness-[0.98]',
|
||
isLight
|
||
? 'hover:border-sky-400/50'
|
||
: 'hover:border-cyan-300/50 hover:shadow-[0_14px_32px_rgba(8,145,178,0.28)]'
|
||
)}
|
||
style={visualizeButtonStyle}
|
||
onClick={handleOpenGraphTab}
|
||
>
|
||
<Network size={13} className="shrink-0" />
|
||
{t('detail.actions.visualize')}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">
|
||
{t('detail.tooltips.openTeamGraph')}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
{(() => {
|
||
const currentPath = data.config.projectPath;
|
||
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath);
|
||
if (!history || history.length === 0) return null;
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
<History size={10} className="shrink-0" />
|
||
<span className="truncate">
|
||
{t('detail.previous', {
|
||
paths: history.map((p) => formatProjectPath(p)).join(', '),
|
||
})}
|
||
</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{!data.isAlive && !isTeamProvisioning ? (
|
||
<TeamOfflineStatusBanner
|
||
teamName={teamName}
|
||
onLaunch={() => openLaunchDialog('launch')}
|
||
/>
|
||
) : null}
|
||
|
||
<div ref={provisioningBannerRef}>
|
||
<TeamProvisioningBanner teamName={teamName} />
|
||
</div>
|
||
|
||
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
|
||
<div className="mb-3 rounded-md border border-[var(--step-warning-border)] bg-[var(--step-warning-bg)] px-3 py-2 text-xs text-[var(--step-warning-text)]">
|
||
{t('detail.kanbanSafeData')}
|
||
</div>
|
||
) : null}
|
||
{reviewActionError ? (
|
||
<div className="mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-[var(--step-error-text)]">
|
||
{reviewActionError}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="runtime-telemetry-hover-scope">
|
||
<CollapsibleTeamSection
|
||
sectionId="team"
|
||
title={t('detail.sections.team')}
|
||
icon={<Users size={14} />}
|
||
badge={activeTeammateCount === 0 ? t('detail.solo') : activeTeammateCount}
|
||
defaultOpen
|
||
afterBadge={
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="pointer-events-auto h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setAddMemberDialogOpen(true);
|
||
}}
|
||
>
|
||
<UserPlus size={12} />
|
||
{t('detail.actions.add')}
|
||
</Button>
|
||
}
|
||
action={
|
||
<div className="runtime-telemetry-legend flex items-center gap-3 pr-3 text-[11px] font-medium leading-none text-[var(--color-text-muted)] opacity-0 transition-opacity duration-150">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="size-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.3)]" />
|
||
{t('detail.telemetry.memory')}
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="size-2 rounded-full bg-blue-500 shadow-[0_0_6px_rgba(59,130,246,0.3)]" />
|
||
{t('detail.telemetry.cpu')}
|
||
</span>
|
||
</div>
|
||
}
|
||
contentWrapperClassName="-mx-[calc(1rem-5px)] w-[calc(100%+2rem-10px)]"
|
||
>
|
||
<div className="px-[calc(1rem-5px)]">
|
||
<TeamMemberListBridge
|
||
teamName={teamName}
|
||
members={membersWithLiveBranches}
|
||
expectedTeammateCount={activeTeammateCount}
|
||
memberTaskCounts={memberTaskCounts}
|
||
taskMap={taskMap}
|
||
pendingRepliesByMember={pendingRepliesByMember}
|
||
isRosterLoading={loading}
|
||
isTeamAlive={data.isAlive}
|
||
isTeamProvisioning={isTeamProvisioning}
|
||
launchParams={launchParams}
|
||
onMemberClick={handleSelectMember}
|
||
onSendMessage={handleSendMessageToMember}
|
||
onAssignTask={handleAssignTaskToMember}
|
||
onOpenTask={handleOpenTaskById}
|
||
onRestartMember={handleRestartMember}
|
||
onSkipMemberForLaunch={handleSkipMemberForLaunch}
|
||
onRestoreMember={handleRestoreMember}
|
||
/>
|
||
</div>
|
||
</CollapsibleTeamSection>
|
||
</div>
|
||
|
||
<CollapsibleTeamSection
|
||
sectionId="sessions"
|
||
title={t('sessions.title')}
|
||
icon={<History size={14} />}
|
||
defaultOpen={false}
|
||
>
|
||
<TeamSessionsSection
|
||
sessions={teamSessions}
|
||
sessionsLoading={sessionsLoading}
|
||
sessionsError={sessionsError}
|
||
leadSessionId={data.config.leadSessionId}
|
||
selectedSessionId={kanbanFilter.sessionId}
|
||
onSelectSession={(id) => setKanbanFilter((prev) => ({ ...prev, sessionId: id }))}
|
||
projectPath={data.config.projectPath}
|
||
/>
|
||
</CollapsibleTeamSection>
|
||
|
||
<CollapsibleTeamSection
|
||
sectionId="kanban"
|
||
title={t('kanban.title')}
|
||
icon={<Columns3 size={14} />}
|
||
badge={filteredTasks.length}
|
||
defaultOpen
|
||
forceOpen={isKanbanSearchActive}
|
||
action={
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openCreateTaskDialog();
|
||
}}
|
||
>
|
||
<Plus size={12} />
|
||
{t('detail.actions.task')}
|
||
</Button>
|
||
}
|
||
>
|
||
<KanbanBoard
|
||
tasks={kanbanDisplayTasks}
|
||
teamName={teamName}
|
||
kanbanState={data.kanbanState}
|
||
filter={kanbanFilter}
|
||
sort={kanbanSort}
|
||
sessions={teamSessions}
|
||
leadSessionId={data.config.leadSessionId}
|
||
members={activeMembers}
|
||
activeTaskLogActivity={activeTaskLogActivity}
|
||
forceShowAllTasks={isKanbanSearchActive}
|
||
onFilterChange={setKanbanFilter}
|
||
onSortChange={setKanbanSort}
|
||
toolbarLeft={
|
||
<KanbanSearchInput
|
||
value={kanbanSearch}
|
||
onChange={setKanbanSearch}
|
||
tasks={filteredTasks}
|
||
members={activeMembers}
|
||
/>
|
||
}
|
||
onRequestReview={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await requestReview(teamName, taskId);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onApprove={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await updateKanban(teamName, taskId, {
|
||
op: 'set_column',
|
||
column: 'approved',
|
||
});
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onRequestChanges={(taskId) => {
|
||
setRequestChangesTaskId(taskId);
|
||
}}
|
||
onMoveBackToDone={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await updateKanban(teamName, taskId, { op: 'remove' });
|
||
await updateTaskStatus(teamName, taskId, 'completed');
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onStartTask={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
const result = await startTaskByUser(teamName, taskId);
|
||
if (data?.isAlive) {
|
||
const task = data.tasks.find((t) => t.id === taskId);
|
||
try {
|
||
if (result.notifiedOwner && task?.owner) {
|
||
await api.teams.processSend(
|
||
teamName,
|
||
`Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.`
|
||
);
|
||
} else if (!result.notifiedOwner) {
|
||
const desc = task?.description?.trim()
|
||
? `\nDescription: ${task.description.trim()}`
|
||
: '';
|
||
await api.teams.processSend(
|
||
teamName,
|
||
`Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.`
|
||
);
|
||
}
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onCompleteTask={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await updateTaskStatus(teamName, taskId, 'completed');
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onCancelTask={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
const task = data?.tasks.find((t) => t.id === taskId);
|
||
await updateTaskStatus(teamName, taskId, 'pending');
|
||
|
||
// Notify assignee directly via inbox — they'll see it immediately
|
||
if (task?.owner) {
|
||
try {
|
||
await api.teams.sendMessage(teamName, {
|
||
member: task.owner,
|
||
text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`,
|
||
summary: `Task ${formatTaskDisplayLabel(task)} cancelled`,
|
||
});
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
|
||
// Also notify team lead so they can reassign/coordinate
|
||
if (data?.isAlive) {
|
||
try {
|
||
const ownerSuffix = task?.owner
|
||
? ` ${task.owner} has been notified to stop.`
|
||
: '';
|
||
await api.teams.processSend(
|
||
teamName,
|
||
`Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}`
|
||
);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onColumnOrderChange={(columnId, orderedTaskIds) => {
|
||
void (async () => {
|
||
try {
|
||
await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onScrollToTask={(taskId) => {
|
||
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
el.classList.remove('kanban-card-focus-pulse');
|
||
void (el as HTMLElement).offsetWidth;
|
||
el.classList.add('kanban-card-focus-pulse');
|
||
el.addEventListener(
|
||
'animationend',
|
||
() => el.classList.remove('kanban-card-focus-pulse'),
|
||
{ once: true }
|
||
);
|
||
}
|
||
}}
|
||
onTaskClick={(task) => setSelectedTask(task)}
|
||
onViewChanges={handleViewChanges}
|
||
onAddTask={(startImmediately) =>
|
||
openCreateTaskDialog('', '', '', startImmediately)
|
||
}
|
||
onDeleteTask={handleDeleteTask}
|
||
deletedTaskCount={deletedTasks.length}
|
||
onOpenTrash={() => setTrashOpen(true)}
|
||
/>
|
||
</CollapsibleTeamSection>
|
||
|
||
<TeamChangesSection
|
||
teamName={teamName}
|
||
tasks={data.tasks}
|
||
memberColorMap={resolvedMemberColorMap}
|
||
onOpenTask={(task) => setSelectedTask(task)}
|
||
onViewChanges={handleViewChangesForFile}
|
||
/>
|
||
|
||
<CollapsibleTeamSection
|
||
sectionId="schedules"
|
||
title={t('schedule.title')}
|
||
icon={<Clock size={14} />}
|
||
defaultOpen={false}
|
||
>
|
||
<ScheduleSection teamName={teamName} />
|
||
</CollapsibleTeamSection>
|
||
|
||
<LiveRuntimeStatusBridge teamName={teamName} members={membersWithLiveBranches} />
|
||
|
||
{(data.processes?.length ?? 0) > 0 && (
|
||
<CollapsibleTeamSection
|
||
sectionId="processes"
|
||
title={t('processes.title')}
|
||
icon={<Terminal size={14} />}
|
||
badge={data.processes.filter((p) => !p.stoppedAt).length}
|
||
headerExtra={
|
||
data.processes.some((p) => !p.stoppedAt) ? (
|
||
<span
|
||
className="pointer-events-none relative inline-flex size-2 shrink-0"
|
||
title={t('detail.status.active')}
|
||
>
|
||
<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
|
||
}
|
||
defaultOpen
|
||
>
|
||
<ProcessesSection
|
||
teamName={teamName}
|
||
members={membersWithLiveBranches}
|
||
processes={data.processes}
|
||
/>
|
||
</CollapsibleTeamSection>
|
||
)}
|
||
|
||
{messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />}
|
||
|
||
{messagesPanelMode === 'inline' && (
|
||
<TeamMessagesPanelBridge position="inline" {...sharedMessagesPanelProps} />
|
||
)}
|
||
|
||
<ReviewDialog
|
||
open={requestChangesTaskId !== null}
|
||
teamName={teamName}
|
||
taskId={requestChangesTaskId}
|
||
members={members}
|
||
onCancel={() => setRequestChangesTaskId(null)}
|
||
onSubmit={(comment, taskRefs) => {
|
||
if (!requestChangesTaskId) {
|
||
return;
|
||
}
|
||
void (async () => {
|
||
try {
|
||
await updateKanban(teamName, requestChangesTaskId, {
|
||
op: 'request_changes',
|
||
comment,
|
||
taskRefs,
|
||
});
|
||
setRequestChangesTaskId(null);
|
||
} catch {
|
||
// error state is handled in the store and shown in the view
|
||
}
|
||
})();
|
||
}}
|
||
/>
|
||
|
||
<TeamMemberDetailDialogBridge
|
||
open={selectedMember !== null}
|
||
member={selectedMember}
|
||
teamName={teamName}
|
||
members={membersWithLiveBranches}
|
||
tasks={data.tasks}
|
||
initialTab={selectedMemberView?.initialTab}
|
||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||
isTeamAlive={data.isAlive}
|
||
isTeamProvisioning={isTeamProvisioning}
|
||
launchParams={launchParams}
|
||
onClose={closeSelectedMemberDialog}
|
||
onSendMessage={() => {
|
||
const name = selectedMember?.name ?? '';
|
||
closeSelectedMemberDialog();
|
||
setSendDialogRecipient(name || undefined);
|
||
setSendDialogDefaultText(undefined);
|
||
setSendDialogDefaultChip(undefined);
|
||
setReplyQuote(undefined);
|
||
setSendDialogOpen(true);
|
||
}}
|
||
onAssignTask={() => {
|
||
const name = selectedMember?.name ?? '';
|
||
closeSelectedMemberDialog();
|
||
openCreateTaskDialog('', '', name);
|
||
}}
|
||
onRestartMember={handleRestartMember}
|
||
onTaskClick={(task) => {
|
||
closeSelectedMemberDialog();
|
||
setSelectedTask(task);
|
||
}}
|
||
onUpdateRole={async (memberName, role) => {
|
||
setUpdatingRoleLoading(true);
|
||
try {
|
||
await updateMemberRole(teamName, memberName, role);
|
||
// Optimistically update local selectedMember to reflect new role
|
||
setSelectedMember((prev) => {
|
||
if (prev?.name !== memberName) return prev;
|
||
const normalized =
|
||
typeof role === 'string' && role.trim() ? role.trim() : undefined;
|
||
return { ...prev, role: normalized };
|
||
});
|
||
} finally {
|
||
setUpdatingRoleLoading(false);
|
||
}
|
||
}}
|
||
updatingRole={updatingRoleLoading}
|
||
onRemoveMember={() => {
|
||
const name = selectedMember?.name;
|
||
if (!name) return;
|
||
setRemoveMemberConfirm(name);
|
||
}}
|
||
onViewMemberChanges={(memberName, filePath) => {
|
||
closeSelectedMemberDialog();
|
||
setReviewDialogState({
|
||
open: true,
|
||
mode: 'agent',
|
||
memberName,
|
||
initialFilePath: filePath,
|
||
});
|
||
}}
|
||
/>
|
||
|
||
{createTaskDialog.open && (
|
||
<Suspense fallback={null}>
|
||
<CreateTaskDialog
|
||
open={createTaskDialog.open}
|
||
teamName={teamName}
|
||
members={activeMembers}
|
||
tasks={data.tasks}
|
||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||
defaultSubject={createTaskDialog.defaultSubject}
|
||
defaultDescription={createTaskDialog.defaultDescription}
|
||
defaultOwner={createTaskDialog.defaultOwner}
|
||
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
|
||
defaultChip={createTaskDialog.defaultChip}
|
||
onClose={closeCreateTaskDialog}
|
||
onSubmit={handleCreateTask}
|
||
submitting={creatingTask}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
|
||
<EditTeamDialog
|
||
open={editDialogOpen}
|
||
teamName={teamName}
|
||
currentName={data.config.name}
|
||
currentDescription={data.config.description ?? ''}
|
||
currentColor={data.config.color ?? ''}
|
||
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
||
leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null}
|
||
resolvedMemberColorMap={resolvedMemberColorMap}
|
||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||
isTeamProvisioning={isTeamProvisioning}
|
||
projectPath={data.config.projectPath}
|
||
onClose={() => setEditDialogOpen(false)}
|
||
onChangeLeadRuntime={handleChangeLeadRuntime}
|
||
onSaved={() => void selectTeam(teamName)}
|
||
/>
|
||
|
||
<AddMemberDialog
|
||
open={addMemberDialogOpen}
|
||
teamName={teamName}
|
||
existingNames={membersWithLiveBranches.map((m) => m.name)}
|
||
existingMembers={membersWithLiveBranches}
|
||
projectPath={data.config.projectPath}
|
||
adding={addingMemberLoading}
|
||
onClose={() => setAddMemberDialogOpen(false)}
|
||
onAdd={(entries: AddMemberEntry[]) => {
|
||
setAddingMemberLoading(true);
|
||
void (async () => {
|
||
try {
|
||
for (const entry of entries) {
|
||
await addMember(teamName, {
|
||
name: entry.name,
|
||
role: entry.role,
|
||
workflow: entry.workflow,
|
||
isolation: entry.isolation,
|
||
providerId: entry.providerId,
|
||
model: entry.model,
|
||
effort: entry.effort,
|
||
mcpPolicy: entry.mcpPolicy,
|
||
});
|
||
}
|
||
setAddMemberDialogOpen(false);
|
||
} catch {
|
||
// error shown via store
|
||
} finally {
|
||
setAddingMemberLoading(false);
|
||
}
|
||
})();
|
||
}}
|
||
/>
|
||
|
||
<Dialog
|
||
open={removeMemberConfirm !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setRemoveMemberConfirm(null);
|
||
}}
|
||
>
|
||
<DialogContent className="max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('detail.removeMember.title')}</DialogTitle>
|
||
<DialogDescription>
|
||
{t('detail.removeMember.description', { member: removeMemberConfirm })}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="ghost" size="sm" onClick={() => setRemoveMemberConfirm(null)}>
|
||
{t('detail.actions.cancel')}
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
onClick={() => {
|
||
const name = removeMemberConfirm;
|
||
setRemoveMemberConfirm(null);
|
||
closeSelectedMemberDialog();
|
||
if (name) void removeMember(teamName, name);
|
||
}}
|
||
>
|
||
{t('detail.actions.remove')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||
<DialogContent className="max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('detail.deleteTeam.title')}</DialogTitle>
|
||
<DialogDescription>
|
||
{t('detail.deleteTeam.description', { team: data.config.name })}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirmOpen(false)}>
|
||
{t('detail.actions.cancel')}
|
||
</Button>
|
||
<Button variant="destructive" size="sm" onClick={confirmDeleteTeam}>
|
||
{t('detail.actions.delete')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{launchDialogOpen && (
|
||
<Suspense
|
||
fallback={
|
||
<LaunchTeamDialogLoadingFallback
|
||
mode={launchDialogState.mode}
|
||
teamName={teamName}
|
||
onClose={closeLaunchDialog}
|
||
/>
|
||
}
|
||
>
|
||
<LaunchTeamDialog
|
||
mode={launchDialogState.mode}
|
||
open={launchDialogOpen}
|
||
teamName={teamName}
|
||
members={membersWithLiveBranches}
|
||
defaultProjectPath={data.config.projectPath}
|
||
provisioningError={provisioningError}
|
||
clearProvisioningError={clearProvisioningError}
|
||
activeTeams={activeTeamsForLaunch}
|
||
onClose={closeLaunchDialog}
|
||
onLaunch={handleLaunchDialogSubmit}
|
||
onRelaunch={handleRelaunchDialogSubmit}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
|
||
{sendDialogOpen && (
|
||
<Suspense fallback={null}>
|
||
<SendMessageDialog
|
||
open={sendDialogOpen}
|
||
teamName={teamName}
|
||
members={activeMembers}
|
||
defaultRecipient={sendDialogRecipient}
|
||
defaultText={sendDialogDefaultText}
|
||
defaultChip={sendDialogDefaultChip}
|
||
quotedMessage={replyQuote}
|
||
isTeamAlive={data.isAlive}
|
||
sending={sendingMessage}
|
||
sendError={sendMessageError}
|
||
sendWarning={sendMessageWarning}
|
||
sendDebugDetails={sendMessageDebugDetails}
|
||
lastResult={lastSendMessageResult}
|
||
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
|
||
const sentAtMs = Date.now();
|
||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||
try {
|
||
const result = await sendTeamMessage(teamName, {
|
||
member,
|
||
text,
|
||
summary,
|
||
attachments,
|
||
actionMode,
|
||
taskRefs,
|
||
});
|
||
if (
|
||
shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)
|
||
) {
|
||
setPendingRepliesByMember((prev) => {
|
||
if (prev[member] !== sentAtMs) return prev;
|
||
const next = { ...prev };
|
||
delete next[member];
|
||
return next;
|
||
});
|
||
}
|
||
return result;
|
||
} catch (error) {
|
||
setPendingRepliesByMember((prev) => {
|
||
if (prev[member] !== sentAtMs) return prev;
|
||
const next = { ...prev };
|
||
delete next[member];
|
||
return next;
|
||
});
|
||
throw error;
|
||
}
|
||
}}
|
||
onClose={() => {
|
||
setSendDialogOpen(false);
|
||
setReplyQuote(undefined);
|
||
setSendDialogDefaultText(undefined);
|
||
setSendDialogDefaultChip(undefined);
|
||
}}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
|
||
{selectedTask !== null && (
|
||
<Suspense fallback={null}>
|
||
<TaskDetailDialog
|
||
open={selectedTask !== null}
|
||
task={selectedTask}
|
||
teamName={teamName}
|
||
kanbanTaskState={
|
||
selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined
|
||
}
|
||
taskMap={taskMap}
|
||
members={activeMembers}
|
||
onClose={() => setSelectedTask(null)}
|
||
onScrollToTask={(taskId) => {
|
||
setSelectedTask(null);
|
||
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
el.classList.remove('kanban-card-focus-pulse');
|
||
void (el as HTMLElement).offsetWidth;
|
||
el.classList.add('kanban-card-focus-pulse');
|
||
el.addEventListener(
|
||
'animationend',
|
||
() => el.classList.remove('kanban-card-focus-pulse'),
|
||
{ once: true }
|
||
);
|
||
}
|
||
}}
|
||
onOwnerChange={(taskId, owner) => {
|
||
void (async () => {
|
||
try {
|
||
await updateTaskOwner(teamName, taskId, owner);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onViewChanges={handleViewChangesForFile}
|
||
onOpenInEditor={(filePath) => {
|
||
const { revealFileInEditor } = useStore.getState();
|
||
revealFileInEditor(filePath);
|
||
}}
|
||
onDeleteTask={handleDeleteTask}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
|
||
<TrashDialog
|
||
open={trashOpen}
|
||
tasks={deletedTasks}
|
||
onClose={() => setTrashOpen(false)}
|
||
onRestore={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await restoreTask(teamName, taskId);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
/>
|
||
|
||
{reviewDialogState.open && (
|
||
<Suspense fallback={null}>
|
||
<ChangeReviewDialog
|
||
open={reviewDialogState.open}
|
||
onOpenChange={(open) =>
|
||
setReviewDialogState((prev) => ({
|
||
...prev,
|
||
open,
|
||
...(open
|
||
? {}
|
||
: { initialFilePath: undefined, taskChangeRequestOptions: undefined }),
|
||
}))
|
||
}
|
||
teamName={teamName}
|
||
mode={reviewDialogState.mode}
|
||
memberName={reviewDialogState.memberName}
|
||
taskId={reviewDialogState.taskId}
|
||
initialFilePath={reviewDialogState.initialFilePath}
|
||
taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions}
|
||
projectPath={data.config.projectPath}
|
||
onEditorAction={handleEditorAction}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</div>
|
||
<div
|
||
ref={setMessagesPanelMountPoint}
|
||
className="pointer-events-none absolute inset-0 z-30"
|
||
/>
|
||
{messagesPanelMode === 'bottom-sheet' && !graphOpen && (
|
||
<TeamMessagesPanelBridge position="bottom-sheet" {...sharedMessagesPanelProps} />
|
||
)}
|
||
{messagesPanelMode === 'floating-composer' &&
|
||
isThisTabActive &&
|
||
isPaneFocused &&
|
||
!graphOpen && (
|
||
<TeamMessagesPanelBridge
|
||
position="floating-composer"
|
||
{...sharedMessagesPanelProps}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{editorOpen && data.config.projectPath && (
|
||
<Suspense fallback={null}>
|
||
<ProjectEditorOverlay
|
||
projectPath={data.config.projectPath}
|
||
onClose={() => setEditorOpen(false)}
|
||
onEditorAction={handleEditorAction}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
|
||
{graphOpen && (
|
||
<Suspense fallback={null}>
|
||
<TeamGraphOverlay
|
||
teamName={teamName}
|
||
onClose={() => setGraphOpen(false)}
|
||
onPinAsTab={() => {
|
||
setGraphOpen(false);
|
||
useStore
|
||
.getState()
|
||
.openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName });
|
||
}}
|
||
messagesPanelEnabled={
|
||
(messagesPanelMode === 'floating-composer' ||
|
||
messagesPanelMode === 'bottom-sheet') &&
|
||
isThisTabActive &&
|
||
isPaneFocused
|
||
}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{spawnStatusWatcher}
|
||
{teamAgentRuntimeWatcher}
|
||
{renderBody()}
|
||
</>
|
||
);
|
||
});
|