feat: add lead activity tracking for team management

- Introduced new IPC channel for retrieving lead activity state (active, idle, offline) for teams.
- Implemented lead activity state management in TeamProvisioningService, allowing real-time updates.
- Enhanced team detail and list views to display lead activity status, improving user awareness of team dynamics.
- Updated member components to reflect lead activity, providing better context for team members' statuses.
- Added localStorage fallback for comment read state management, ensuring data persistence across sessions.
This commit is contained in:
iliya 2026-02-25 13:18:37 +02:00
parent fd3176716b
commit e4aa544f57
33 changed files with 1013 additions and 536 deletions

View file

@ -17,6 +17,7 @@ import {
TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
@ -193,6 +194,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
logger.info('Team handlers registered');
}
@ -229,6 +231,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
}
function getTeamDataService(): TeamDataService {
@ -1263,6 +1266,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
}
async function handleLeadActivity(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<string>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('leadActivity', async () =>
getTeamProvisioningService().getLeadActivityState(validated.value!)
);
}
async function handleStopTeam(
_event: IpcMainInvokeEvent,
teamName: unknown

View file

@ -133,8 +133,12 @@ interface ProvisioningRun {
provisioningOutputParts: string[];
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
detectedSessionId: string | null;
/** Lead process activity: 'active' during turn processing, 'idle' waiting for input, 'offline' after exit. */
leadActivityState: LeadActivityState;
}
type LeadActivityState = 'active' | 'idle' | 'offline';
type ProvisioningAuthSource =
| 'anthropic_api_key'
| 'anthropic_auth_token'
@ -609,6 +613,24 @@ export class TeamProvisioningService {
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
}
getLeadActivityState(teamName: string): 'active' | 'idle' | 'offline' {
const runId = this.activeByTeam.get(teamName);
if (!runId) return 'offline';
const run = this.runs.get(runId);
if (!run || run.processKilled || run.cancelRequested) return 'offline';
return run.leadActivityState;
}
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
if (run.leadActivityState === state) return;
run.leadActivityState = state;
this.teamChangeEmitter?.({
type: 'lead-activity',
teamName: run.teamName,
detail: state,
});
}
async warmup(): Promise<void> {
try {
const claudePath = await ClaudeBinaryResolver.resolve();
@ -768,6 +790,7 @@ export class TeamProvisioningService {
directReplyParts: [],
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
progress: {
runId,
teamName: request.teamName,
@ -1040,6 +1063,7 @@ export class TeamProvisioningService {
directReplyParts: [],
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
progress: {
runId,
teamName: request.teamName,
@ -1283,6 +1307,7 @@ export class TeamProvisioningService {
},
});
run.child.stdin.write(payload + '\n');
this.setLeadActivity(run, 'active');
}
/**
@ -1740,6 +1765,9 @@ export class TeamProvisioningService {
})();
if (subtype === 'success') {
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
if (run.provisioningComplete) {
this.setLeadActivity(run, 'idle');
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
const combined = capture.textParts.join('').trim();
@ -1800,6 +1828,9 @@ export class TeamProvisioningService {
run.child?.stdin?.end();
run.child?.kill();
this.cleanupRun(run);
} else if (run.provisioningComplete) {
// Post-provisioning error: process alive, waiting for input
this.setLeadActivity(run, 'idle');
}
}
}
@ -1813,6 +1844,7 @@ export class TeamProvisioningService {
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
if (run.cancelRequested) return;
run.provisioningComplete = true;
this.setLeadActivity(run, 'idle');
// Clear provisioning timeout — no longer needed
if (run.timeoutHandle) {
@ -1880,6 +1912,7 @@ export class TeamProvisioningService {
* Remove a run from tracking maps.
*/
private cleanupRun(run: ProvisioningRun): void {
this.setLeadActivity(run, 'offline');
if (run.timeoutHandle) {
clearTimeout(run.timeoutHandle);
run.timeoutHandle = null;

View file

@ -285,6 +285,9 @@ export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole';
/** Get attachment data for a message */
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
/** Get lead process activity state (active/idle/offline) */
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
// =============================================================================
// Review API Channels
// =============================================================================

View file

@ -47,6 +47,7 @@ import {
TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
@ -663,6 +664,10 @@ const electronAPI: ElectronAPI = {
getAttachments: async (teamName: string, messageId: string) => {
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
},
getLeadActivity: async (teamName: string) => {
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
return result as 'active' | 'idle' | 'offline';
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,
@ -761,9 +766,9 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
},
onCmdN: (callback: () => void): (() => void) => {
const handler = () => callback();
const handler = (): void => callback();
ipcRenderer.on('review:cmdN', handler);
return () => {
return (): void => {
ipcRenderer.removeListener('review:cmdN', handler);
};
},

View file

@ -761,6 +761,9 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<AttachmentFileData[]> => {
return [];
},
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
return 'offline';
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)
@ -775,44 +778,44 @@ export class HttpAPIClient implements ElectronAPI {
// Review API stubs
review = {
getAgentChanges: async (_teamName: string, _memberName: string) => {
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getTaskChanges: async (_teamName: string, _taskId: string) => {
getTaskChanges: async (_teamName: string, _taskId: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getChangeStats: async (_teamName: string, _memberName: string) => {
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getFileContent: async (
_teamName: string,
_memberName: string | undefined,
_filePath: string
) => {
): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
applyDecisions: async () => {
applyDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Phase 2 stubs
checkConflict: async () => {
checkConflict: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
rejectHunks: async () => {
rejectHunks: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
rejectFile: async () => {
rejectFile: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
previewReject: async () => {
previewReject: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Editable diff stubs
saveEditedFile: async () => {
saveEditedFile: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Phase 4 stubs
getGitFileLog: async () => {
getGitFileLog: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
};

View file

@ -168,6 +168,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
launchTeam,
provisioningError,
isTeamProvisioning,
leadActivityByTeam,
refreshTeamData,
kanbanFilterQuery,
clearKanbanFilter,
@ -201,6 +202,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
isTeamProvisioning: Object.values(s.provisioningRuns).some(
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
),
leadActivityByTeam: s.leadActivityByTeam,
refreshTeamData: s.refreshTeamData,
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
@ -792,6 +794,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
pendingRepliesByMember={pendingRepliesByMember}
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]}
onMemberClick={setSelectedMember}
onSendMessage={(member) => {
setSendDialogRecipient(member.name);
@ -1125,6 +1128,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
messages={data.messages}
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivityByTeam[teamName]}
onClose={() => setSelectedMember(null)}
onSendMessage={() => {
const name = selectedMember?.name ?? '';

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
@ -12,6 +13,7 @@ import {
} from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
import {
@ -31,7 +33,12 @@ import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamCreateRequest, TeamProvisioningProgress, TeamSummary } from '@shared/types';
import type {
TeamCreateRequest,
TeamProvisioningProgress,
TeamSummary,
TeamSummaryMember,
} from '@shared/types';
function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, '');
@ -44,7 +51,7 @@ function generateUniqueName(sourceName: string, existingNames: string[]): string
}
}
type TeamStatus = 'running' | 'provisioning' | 'offline';
type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline';
function getRecentProjects(team: TeamSummary): string[] {
const history = team.projectPathHistory;
@ -58,13 +65,69 @@ function folderName(fullPath: string): string {
return getBaseName(fullPath) || fullPath;
}
function renderMemberChips(members: TeamSummaryMember[]): React.JSX.Element {
const teamColorMap = buildMemberColorMap(members);
return (
<>
{members.map((m) => {
const resolvedColor = teamColorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span>
{m.role ? (
<span className="text-[9px] text-[var(--color-text-muted)]">{m.role}</span>
) : null}
</span>
);
})}
</>
);
}
function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX.Element | null {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && (status === 'active' || status === 'idle') ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
}
function resolveTeamStatus(
teamName: string,
aliveTeams: string[],
provisioningRuns: Record<string, TeamProvisioningProgress>
provisioningRuns: Record<string, TeamProvisioningProgress>,
leadActivityByTeam: Record<string, string>
): TeamStatus {
if (aliveTeams.includes(teamName)) {
return 'running';
return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle';
}
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
for (const run of Object.values(provisioningRuns)) {
@ -77,10 +140,17 @@ function resolveTeamStatus(
const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
switch (status) {
case 'running':
case 'active':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
Active
</span>
);
case 'idle':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-400" />
Running
</span>
);
@ -141,14 +211,16 @@ export const TeamListView = (): React.JSX.Element => {
activeProjectId: s.activeProjectId,
}))
);
const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
provisioningError: s.provisioningError,
provisioningRuns: s.provisioningRuns,
}))
);
const { connectionMode, createTeam, provisioningError, provisioningRuns, leadActivityByTeam } =
useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
provisioningError: s.provisioningError,
provisioningRuns: s.provisioningRuns,
leadActivityByTeam: s.leadActivityByTeam,
}))
);
const canCreate = electronMode && connectionMode === 'local';
// Fetch alive teams on mount and when teams list changes
@ -275,11 +347,18 @@ export const TeamListView = (): React.JSX.Element => {
const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm(`Delete team "${teamName}"? This action is irreversible.`);
if (!confirmed) {
return;
}
void deleteTeam(teamName);
void (async () => {
const confirmed = await confirm({
title: 'Delete team',
message: `Delete team "${teamName}"? This action is irreversible.`,
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (confirmed) {
void deleteTeam(teamName);
}
})();
},
[deleteTeam]
);
@ -441,22 +520,17 @@ export const TeamListView = (): React.JSX.Element => {
</div>
);
if (teamsLoading) {
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
const renderContent = (): React.JSX.Element => {
if (teamsLoading) {
return (
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Loading teams...
</div>
{createDialogElement}
</div>
);
}
);
}
if (teamsError) {
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
if (teamsError) {
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">Failed to load teams</p>
@ -473,259 +547,212 @@ export const TeamListView = (): React.JSX.Element => {
</Button>
</div>
</div>
{createDialogElement}
</div>
);
}
);
}
if (teams.length === 0) {
return <TeamEmptyState />;
}
if (filteredTeams.length === 0 && searchQuery.trim()) {
return (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot;
</div>
);
}
if (teams.length === 0) {
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
<TeamEmptyState />
{createDialogElement}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => {
const status = resolveTeamStatus(
team.teamName,
aliveTeams,
provisioningRuns,
leadActivityByTeam
);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
const matchesCurrentProject =
!!currentProjectPath &&
((team.projectPath ? normalizePath(team.projectPath) === currentProjectPath : false) ||
(team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
false));
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
}
}}
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
{(status === 'active' || status === 'idle') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
>
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
<div className="mt-2 flex min-h-10 items-start gap-2">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
if (!branch) return null;
return (
<span
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branch}
>
<GitBranch size={10} />
<span className="max-w-24 truncate">{branch}</span>
</span>
);
})()}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
renderMemberChips(team.members)
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return (
<div className="mt-2 w-full space-y-1.5">
<div className="flex items-center gap-2">
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
);
})()}
</div>
{renderTeamRecentPaths(team, status)}
</div>
</div>
);
})}
</div>
);
}
};
return (
<TooltipProvider delayDuration={300}>
<div className="size-full overflow-auto p-4">
{renderHeader()}
{filteredTeams.length === 0 && searchQuery.trim() ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot;
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
const matchesCurrentProject =
!!currentProjectPath &&
(() => {
if (team.projectPath && normalizePath(team.projectPath) === currentProjectPath)
return true;
return (
team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
false
);
})();
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
}
}}
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
{status === 'running' && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
>
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
<div className="mt-2 flex min-h-10 items-start gap-2">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
if (!branch) return null;
return (
<span
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branch}
>
<GitBranch size={10} />
<span className="max-w-24 truncate">{branch}</span>
</span>
);
})()}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span>
{m.role ? (
<span className="text-[9px] text-[var(--color-text-muted)]">
{m.role}
</span>
) : null}
</span>
);
})
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return (
<div className="mt-2 w-full space-y-1.5">
<div className="flex items-center gap-2">
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
);
})()}
</div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && status === 'running' ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
})()}
</div>
</div>
);
})}
</div>
)}
{renderContent()}
{createDialogElement}
</div>
</TooltipProvider>

View file

@ -1,6 +1,7 @@
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Loader2 } from 'lucide-react';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -18,6 +19,7 @@ export const ActiveTasksBlock = ({
onMemberClick,
onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null => {
const colorMap = buildMemberColorMap(members);
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const working = members.filter((m) => m.currentTaskId != null);
if (working.length === 0) return null;
@ -30,7 +32,7 @@ export const ActiveTasksBlock = ({
{working.map((member) => {
const taskId = member.currentTaskId!;
const task = taskMap.get(taskId);
const colors = getTeamColorSet(member.color ?? '');
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem } from './ActivityItem';
@ -104,35 +104,27 @@ export const ActivityTimeline = ({
onMemberClick,
onMessageVisible,
}: ActivityTimelineProps): React.JSX.Element => {
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const memberInfo = new Map<string, { role?: string; color?: string }>();
if (members) {
for (const m of members) {
const info = {
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
color: m.color,
color: colorMap.get(m.name),
};
memberInfo.set(m.name, info);
if (m.agentType && m.agentType !== m.name) {
memberInfo.set(m.agentType, info);
}
}
// Map "user" to team-lead's resolved color and role
const leadMember = members.find(
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
);
if (leadMember) {
const leadInfo = memberInfo.get(leadMember.name);
if (leadInfo) {
const teamLeadColor = leadInfo.color ?? getMemberColorByName('team-lead');
const resolvedLeadInfo = { role: leadInfo.role, color: teamLeadColor };
memberInfo.set('team-lead', resolvedLeadInfo);
memberInfo.set(leadMember.name, resolvedLeadInfo);
if (
leadMember.agentType &&
leadMember.agentType !== 'team-lead' &&
leadMember.agentType !== leadMember.name
) {
memberInfo.set(leadMember.agentType, resolvedLeadInfo);
}
memberInfo.set('user', { role: leadInfo.role, color: colorMap.get('user') });
}
}
}
@ -157,7 +149,7 @@ export const ActivityTimeline = ({
const info = memberInfo.get(message.from);
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor =
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message))

View file

@ -1,6 +1,7 @@
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { formatDistanceToNowStrict } from 'date-fns';
import { Loader2 } from 'lucide-react';
@ -17,6 +18,7 @@ export const PendingRepliesBlock = ({
pendingRepliesByMember,
onMemberClick,
}: PendingRepliesBlockProps): React.JSX.Element | null => {
const colorMap = buildMemberColorMap(members);
const pending = Object.entries(pendingRepliesByMember)
.map(([name, sentAtMs]) => ({
member: members.find((m) => m.name === name) ?? null,
@ -34,7 +36,7 @@ export const PendingRepliesBlock = ({
Awaiting replies
</p>
{pending.map(({ member, sentAtMs }) => {
const colors = getTeamColorSet(member.color ?? '');
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);

View file

@ -24,6 +24,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertTriangle, Search } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -66,6 +67,7 @@ export const CreateTaskDialog = ({
onSubmit,
submitting = false,
}: CreateTaskDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const [subject, setSubject] = useState(defaultSubject);
const descriptionDraft = useDraftPersistence({
key: `createTask:${teamName}:description`,
@ -103,9 +105,9 @@ export const CreateTaskDialog = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const requiresOwner = defaultStartImmediately === true;
@ -161,7 +163,8 @@ export const CreateTaskDialog = ({
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">

View file

@ -25,6 +25,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
@ -248,15 +249,16 @@ export const LaunchTeamDialog = ({
);
}, [activeTeams, effectiveCwd, teamName]);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const activeError = localError ?? provisioningError;

View file

@ -13,6 +13,7 @@ import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
@ -38,6 +39,7 @@ export const ReviewDialog = ({
key: `requestChanges:${teamName}:${taskId ?? ''}`,
enabled: Boolean(teamName && taskId),
});
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
@ -45,9 +47,9 @@ export const ReviewDialog = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const handleCancel = (): void => {

View file

@ -24,6 +24,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -59,6 +60,7 @@ export const SendMessageDialog = ({
onSend,
onClose,
}: SendMessageDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
const [member, setMember] = useState('');
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
@ -102,9 +104,9 @@ export const SendMessageDialog = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
@ -145,7 +147,8 @@ export const SendMessageDialog = ({
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">

View file

@ -4,12 +4,14 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
@ -52,6 +54,7 @@ export const TaskCommentsSection = ({
}, []);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
@ -59,9 +62,9 @@ export const TaskCommentsSection = ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const trimmed = draft.value.trim();
@ -102,11 +105,10 @@ export const TaskCommentsSection = ({
<span
className="font-medium"
style={{
color:
comment.author === 'user'
? 'var(--color-text-secondary)'
: (members.find((m) => m.name === comment.author)?.color ??
'var(--color-text-secondary)'),
color: (() => {
const rc = colorMap.get(comment.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
{comment.author}
@ -228,11 +230,10 @@ export const TaskCommentsSection = ({
<span
className="font-semibold"
style={{
color:
replyTo.author === 'user'
? 'var(--color-text-secondary)'
: (members.find((m) => m.name === replyTo.author)?.color ??
'var(--color-text-secondary)'),
color: (() => {
const rc = colorMap.get(replyTo.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
@{replyTo.author}

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
@ -25,6 +25,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
buildMemberColorMap,
KANBAN_COLUMN_DISPLAY,
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
@ -59,6 +60,7 @@ export const TaskDetailDialog = ({
onScrollToTask,
onOwnerChange,
}: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
useEffect(() => {
@ -110,7 +112,6 @@ export const TaskDetailDialog = ({
t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id)
)
.map((t) => t.id);
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange;
@ -151,7 +152,8 @@ export const TaskDetailDialog = ({
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
@ -174,7 +176,11 @@ export const TaskDetailDialog = ({
</SelectContent>
</Select>
) : currentTask.owner ? (
<MemberBadge name={currentTask.owner} color={ownerMember?.color} size="md" />
<MemberBadge
name={currentTask.owner}
color={colorMap.get(currentTask.owner)}
size="md"
/>
) : (
<span className="text-xs text-[var(--color-text-muted)]">&mdash;</span>
)}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
@ -7,6 +7,7 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
ArrowLeftFromLine,
ArrowRightFromLine,
@ -143,6 +144,7 @@ export const KanbanTaskCard = ({
onTaskClick,
onViewChanges,
}: KanbanTaskCardProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
@ -184,12 +186,7 @@ export const KanbanTaskCard = ({
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
{task.owner ? (
<MemberBadge
name={task.owner}
color={members.find((m) => m.name === task.owner)?.color}
/>
) : null}
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>

View file

@ -6,7 +6,7 @@ import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/u
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberCardProps {
member: ResolvedTeamMember;
@ -14,6 +14,7 @@ interface MemberCardProps {
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
currentTask?: TeamTaskWithKanban | null;
isAwaitingReply?: boolean;
isRemoved?: boolean;
@ -29,6 +30,7 @@ export const MemberCard = ({
taskCounts,
isTeamAlive,
isTeamProvisioning,
leadActivity,
currentTask,
isAwaitingReply,
isRemoved,
@ -37,8 +39,8 @@ export const MemberCard = ({
onSendMessage,
onAssignTask,
}: MemberCardProps): React.JSX.Element => {
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0;

View file

@ -12,7 +12,12 @@ import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type {
InboxMessage,
LeadActivityState,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
interface MemberDetailDialogProps {
open: boolean;
@ -22,6 +27,7 @@ interface MemberDetailDialogProps {
messages: InboxMessage[];
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
onClose: () => void;
onSendMessage: () => void;
onAssignTask: () => void;
@ -39,6 +45,7 @@ export const MemberDetailDialog = ({
messages,
isTeamAlive,
isTeamProvisioning,
leadActivity,
onClose,
onSendMessage,
onAssignTask,
@ -80,6 +87,7 @@ export const MemberDetailDialog = ({
member={member}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
}

View file

@ -8,12 +8,13 @@ import { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor';
import type { ResolvedTeamMember } from '@shared/types';
import type { LeadActivityState, ResolvedTeamMember } from '@shared/types';
interface MemberDetailHeaderProps {
member: ResolvedTeamMember;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
updatingRole?: boolean;
}
@ -22,14 +23,15 @@ export const MemberDetailHeader = ({
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
onUpdateRole,
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
const [editing, setEditing] = useState(false);
const role = member.role || formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const canEditRole =
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;

View file

@ -1,9 +1,9 @@
import { getMemberColor } from '@shared/constants/memberColors';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
@ -12,6 +12,7 @@ interface MemberListProps {
pendingRepliesByMember?: Record<string, number>;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
@ -25,6 +26,7 @@ export const MemberList = ({
pendingRepliesByMember,
isTeamAlive,
isTeamProvisioning,
leadActivity,
onMemberClick,
onSendMessage,
onAssignTask,
@ -32,6 +34,7 @@ export const MemberList = ({
}: MemberListProps): React.JSX.Element => {
const activeMembers = members.filter((m) => !m.removedAt);
const removedMembers = members.filter((m) => m.removedAt);
const colorMap = buildMemberColorMap(members);
if (members.length === 0) {
return (
@ -41,11 +44,7 @@ export const MemberList = ({
);
}
const renderCard = (
member: ResolvedTeamMember,
index: number,
isRemoved: boolean
): React.JSX.Element => {
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
@ -53,10 +52,11 @@ export const MemberList = ({
<MemberCard
key={member.name}
member={member}
memberColor={member.color ?? getMemberColor(index)}
memberColor={colorMap.get(member.name) ?? 'blue'}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
@ -70,15 +70,13 @@ export const MemberList = ({
return (
<div className="flex flex-col gap-0.5">
{activeMembers.map((member, index) => renderCard(member, index, false))}
{activeMembers.map((member) => renderCard(member, false))}
{removedMembers.length > 0 && (
<>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length})
</div>
{removedMembers.map((member, index) =>
renderCard(member, activeMembers.length + index, true)
)}
{removedMembers.map((member) => renderCard(member, true))}
</>
)}
</div>

View file

@ -11,6 +11,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, Check, ChevronDown, ImagePlus, Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -61,15 +62,17 @@ export const MessageComposer = ({
handleDrop,
} = useAttachments();
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
color: colorMap.get(m.name),
})),
[members]
[members, colorMap]
);
const trimmed = draft.value.trim();
@ -77,7 +80,8 @@ export const MessageComposer = ({
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
const selectedMember = members.find((m) => m.name === recipient);
const selectedColorSet = selectedMember?.color ? getTeamColorSet(selectedMember.color) : null;
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const selectedColorSet = selectedResolvedColor ? getTeamColorSet(selectedResolvedColor) : null;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
@ -201,7 +205,8 @@ export const MessageComposer = ({
<PopoverContent align="start" className="w-56 p-1.5">
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{members.map((m) => {
const colorSet = m.color ? getTeamColorSet(m.color) : null;
const resolvedColor = colorMap.get(m.name);
const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null;
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const isSelected = m.name === recipient;
return (

View file

@ -74,8 +74,8 @@ export const ChangeReviewDialog = ({
setHunkDecision,
setCollapseUnchanged,
fetchFileContent,
acceptAll,
rejectAll,
acceptAllFile,
rejectAllFile,
applyReview,
// Editable diff
editedContents,
@ -113,15 +113,6 @@ export const ChangeReviewDialog = ({
progress: viewedProgress,
} = useViewedFiles(teamName, scopeKey, allFilePaths);
// When collapseUnchanged changes, invalidate cached state for current file
// so the editor is recreated with the new extension config
useEffect(() => {
if (selectedReviewFilePath) {
editorStateCache.current.delete(selectedReviewFilePath);
}
queueMicrotask(() => setCachedInitialState(undefined));
}, [collapseUnchanged]); // eslint-disable-line react-hooks/exhaustive-deps -- only collapseUnchanged triggers cache invalidation
// Editable diff computed values
const editedCount = Object.keys(editedContents).length;
const hasCurrentFileEdits = !!(
@ -144,14 +135,14 @@ export const ChangeReviewDialog = ({
const handleAcceptAll = useCallback(() => {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
acceptAll();
}, [acceptAll]);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, acceptAllFile]);
const handleRejectAll = useCallback(() => {
const view = editorViewRef.current;
if (view) rejectAllChunks(view);
rejectAll();
}, [rejectAll]);
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, rejectAllFile]);
const handleSaveCurrentFile = useCallback(() => {
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);

View file

@ -22,7 +22,7 @@ import { languages } from '@codemirror/language-data';
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { EditorView, keymap } from '@codemirror/view';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
@ -147,7 +147,14 @@ const diffTheme = EditorView.theme({
backgroundColor: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
fontSize: '12px',
fontSize: '11px',
minWidth: 'auto',
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 4px 0 8px',
minWidth: '2ch',
textAlign: 'right',
opacity: '0.5',
},
'.cm-activeLineGutter': {
backgroundColor: 'transparent',
@ -167,51 +174,79 @@ const diffTheme = EditorView.theme({
'.cm-selectionBackground': {
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
},
// Diff-specific styles — line-level backgrounds (no per-character underlines)
'.cm-changedLine': {
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
// Diff-specific line/block backgrounds
'.cm-changedLine': { backgroundColor: '#1a3a1a !important' },
'.cm-deletedChunk': { backgroundColor: '#241517', position: 'relative', overflow: 'visible' },
'.cm-insertedLine': { backgroundColor: '#1a3a1a !important' },
'.cm-deletedLine': { backgroundColor: '#241517 !important' },
// Merge toolbar — absolute, Y set dynamically by mousemove handler
'.cm-deletedChunk .cm-chunkButtons': {
position: 'absolute',
top: '0',
insetInlineEnd: '8px',
zIndex: 10,
display: 'flex',
justifyContent: 'flex-end',
},
'.cm-deletedChunk': {
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
'.cm-merge-toolbar': {
display: 'none',
alignItems: 'center',
gap: '2px',
'&.cm-merge-toolbar-active': {
display: 'flex',
},
},
'.cm-insertedLine': {
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
'.cm-merge-nav': {
display: 'flex',
alignItems: 'center',
gap: '0',
marginRight: '2px',
border: '1px solid var(--color-border)',
borderRadius: '6px',
backgroundColor: 'var(--color-surface-raised)',
overflow: 'hidden',
},
'.cm-deletedLine': {
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
},
// Merge control buttons
'.cm-merge-accept': {
'.cm-merge-nav-btn': {
border: 'none',
background: 'transparent',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
padding: '0 4px',
margin: '0 2px',
borderRadius: '3px',
fontSize: '11px',
padding: '3px 8px',
fontSize: '13px',
lineHeight: '20px',
'&:hover': { background: 'rgba(255,255,255,0.08)' },
},
'.cm-merge-nav-counter': {
fontSize: '12px',
color: 'var(--color-text-secondary)',
padding: '0 2px',
whiteSpace: 'nowrap',
},
'.cm-merge-undo': {
cursor: 'pointer',
padding: '3px 10px',
borderRadius: '5px',
fontSize: '12px',
fontWeight: '500',
lineHeight: '18px',
display: 'inline-block',
lineHeight: '20px',
color: 'var(--color-text)',
backgroundColor: 'var(--color-surface-raised)',
border: '1px solid var(--color-border)',
'&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' },
'& kbd': { fontSize: '10px', color: 'var(--color-text-muted)', marginLeft: '4px' },
},
'.cm-merge-keep': {
cursor: 'pointer',
padding: '3px 10px',
borderRadius: '5px',
fontSize: '12px',
fontWeight: '500',
lineHeight: '20px',
color: '#3fb950',
backgroundColor: 'rgba(46, 160, 67, 0.15)',
border: '1px solid rgba(46, 160, 67, 0.3)',
'&:hover': {
backgroundColor: 'rgba(46, 160, 67, 0.3)',
},
},
'.cm-merge-reject': {
cursor: 'pointer',
padding: '0 4px',
margin: '0 2px',
borderRadius: '3px',
fontSize: '11px',
fontWeight: '500',
lineHeight: '18px',
display: 'inline-block',
color: '#f85149',
backgroundColor: 'rgba(248, 81, 73, 0.15)',
border: '1px solid rgba(248, 81, 73, 0.3)',
'&:hover': {
backgroundColor: 'rgba(248, 81, 73, 0.3)',
},
backgroundColor: 'rgba(46, 160, 67, 0.25)',
border: '1px solid rgba(46, 160, 67, 0.4)',
'&:hover': { backgroundColor: 'rgba(46, 160, 67, 0.4)' },
'& kbd': { fontSize: '10px', color: 'rgba(63, 185, 80, 0.7)', marginLeft: '4px' },
},
// Collapse unchanged region marker
'.cm-collapsedLines': {
@ -268,10 +303,152 @@ export const CodeMirrorDiffView = ({
// Compartment for lazy-injected language support
const langCompartment = useRef(new Compartment());
// Compartment for merge view — allows dynamic collapse reconfigure without editor recreation
const mergeCompartment = useRef(new Compartment());
// Collapse as ref — used in buildExtensions (initial value) without triggering full rebuild
const collapseRef = useRef({ enabled: collapseUnchangedProp, margin: collapseMargin });
useEffect(() => {
collapseRef.current = { enabled: collapseUnchangedProp, margin: collapseMargin };
}, [collapseUnchangedProp, collapseMargin]);
/** Build unified merge view extension. Extracted for dynamic compartment reconfigure. */
const buildMergeExtension = useCallback(
(collapse: boolean, margin: number): Extension => {
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
original,
highlightChanges: false,
gutter: true,
syntaxHighlightDeletions: true,
};
if (collapse) {
mergeConfig.collapseUnchanged = {
margin,
minSize: 4,
};
}
if (showMergeControls) {
// NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge.
// CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes.
// When EditorView is recreated (e.g. from cached initialState), toDOM() returns
// the OLD cached DOM whose `action` closure references the DESTROYED view.
// Instead, we call acceptChunk/rejectChunk directly with viewRef.current.
//
// CM calls mergeControls twice per chunk: 'accept' first, 'reject' second.
// Both elements go into `.cm-chunkButtons`. We return the full toolbar for
// 'accept' and a hidden span for 'reject'.
mergeConfig.mergeControls = (type, _action) => {
if (type === 'reject') {
const empty = document.createElement('span');
empty.style.display = 'none';
return empty;
}
// --- Full toolbar for 'accept' ---
const toolbar = document.createElement('div');
toolbar.className = 'cm-merge-toolbar';
// Navigation section (hidden by default, shown if >1 chunks)
const nav = document.createElement('div');
nav.className = 'cm-merge-nav';
nav.style.display = 'none';
const prevBtn = document.createElement('button');
prevBtn.className = 'cm-merge-nav-btn';
prevBtn.textContent = '\u2227';
prevBtn.title = 'Previous chunk';
prevBtn.onmousedown = (e) => {
e.preventDefault();
const v = viewRef.current;
if (v) goToPreviousChunk(v);
};
const counter = document.createElement('span');
counter.className = 'cm-merge-nav-counter';
const nextBtn = document.createElement('button');
nextBtn.className = 'cm-merge-nav-btn';
nextBtn.textContent = '\u2228';
nextBtn.title = 'Next chunk';
nextBtn.onmousedown = (e) => {
e.preventDefault();
const v = viewRef.current;
if (v) goToNextChunk(v);
};
nav.append(prevBtn, counter, nextBtn);
toolbar.append(nav);
// Helper: create button with label + kbd shortcut
const makeBtn = (cls: string, label: string, shortcut: string): HTMLButtonElement => {
const btn = document.createElement('button');
btn.className = cls;
btn.append(document.createTextNode(label + ' '));
const kbd = document.createElement('kbd');
kbd.textContent = shortcut;
btn.append(kbd);
return btn;
};
// Undo button (reject action)
const undoBtn = makeBtn('cm-merge-undo', 'Undo', '\u2318N');
undoBtn.title = 'Reject change (⌘N)';
undoBtn.onmousedown = (e) => {
e.preventDefault();
const v = viewRef.current;
if (v) {
const pos = v.posAtDOM(toolbar);
const idx = computeHunkIndexAtPos(v.state, pos);
rejectChunk(v, pos);
onRejectRef.current?.(idx);
scrollToNextChunk();
}
};
toolbar.append(undoBtn);
// Keep button (accept action)
const keepBtn = makeBtn('cm-merge-keep', 'Keep', '\u2318Y');
keepBtn.title = 'Accept change (⌘Y)';
keepBtn.onmousedown = (e) => {
e.preventDefault();
const v = viewRef.current;
if (v) {
const pos = v.posAtDOM(toolbar);
const idx = computeHunkIndexAtPos(v.state, pos);
acceptChunk(v, pos);
onAcceptRef.current?.(idx);
scrollToNextChunk();
}
};
toolbar.append(keepBtn);
// Deferred: compute chunk index + show nav if >1 chunks
requestAnimationFrame(() => {
const v = viewRef.current;
if (!v) return;
const chunks = getChunks(v.state);
if (!chunks || chunks.chunks.length <= 1) return;
const pos = v.posAtDOM(toolbar);
const idx = computeHunkIndexAtPos(v.state, pos);
counter.textContent = `${idx + 1} of ${chunks.chunks.length}`;
nav.style.display = '';
});
return toolbar;
};
}
return unifiedMergeView(mergeConfig);
},
[original, showMergeControls, scrollToNextChunk]
);
const buildExtensions = useCallback(() => {
const extensions: Extension[] = [
diffTheme,
lineNumbers(),
syntaxHighlighting(oneDarkHighlightStyle),
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
@ -339,77 +516,152 @@ export const CodeMirrorDiffView = ({
);
}
// Unified merge view
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
original,
highlightChanges: false,
gutter: true,
syntaxHighlightDeletions: true,
};
if (collapseUnchangedProp) {
mergeConfig.collapseUnchanged = {
margin: collapseMargin,
minSize: 4,
};
}
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
if (showMergeControls) {
// NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge.
// CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes.
// When EditorView is recreated (e.g. from cached initialState), toDOM() returns
// the OLD cached DOM whose `action` closure references the DESTROYED view.
// Instead, we call acceptChunk/rejectChunk directly with viewRef.current.
mergeConfig.mergeControls = (type, _action) => {
const btn = document.createElement('button');
if (type === 'accept') {
btn.textContent = '\u2713';
btn.title = 'Accept change';
btn.className = 'cm-merge-accept';
btn.onmousedown = (e) => {
e.preventDefault();
const view = viewRef.current;
if (view) {
const pos = view.posAtDOM(btn);
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
acceptChunk(view, pos);
onAcceptRef.current?.(hunkIndex);
scrollToNextChunk();
}
};
} else {
btn.textContent = '\u2717';
btn.title = 'Reject change';
btn.className = 'cm-merge-reject';
btn.onmousedown = (e) => {
e.preventDefault();
const view = viewRef.current;
if (view) {
const pos = view.posAtDOM(btn);
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
rejectChunk(view, pos);
onRejectRef.current?.(hunkIndex);
scrollToNextChunk();
}
};
// Helper: position a chunkButtons container so it's below the change block,
// but clamped to the visible viewport if that would be off-screen.
const positionAtBottom = (chunkEl: Element, scroller: Element): void => {
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
if (!btnContainer) return;
const parentRect = chunkEl.getBoundingClientRect();
const scrollerRect = scroller.getBoundingClientRect();
// "below block" = 100% of parent height
let targetY = parentRect.bottom;
const tbHeight = btnContainer.offsetHeight || 28;
// Clamp: if bottom edge would go below visible area, pin to viewport bottom
if (targetY + tbHeight > scrollerRect.bottom) {
targetY = scrollerRect.bottom - tbHeight;
}
return btn;
btnContainer.style.top = `${targetY - parentRect.top}px`;
};
const positionAtCursor = (chunkEl: Element, clientY: number, scroller: Element): void => {
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
if (!btnContainer) return;
const parentRect = chunkEl.getBoundingClientRect();
const scrollerRect = scroller.getBoundingClientRect();
const tbHeight = btnContainer.offsetHeight || 28;
let targetY = clientY - tbHeight / 2;
// Clamp to viewport
if (targetY + tbHeight > scrollerRect.bottom) {
targetY = scrollerRect.bottom - tbHeight;
}
if (targetY < scrollerRect.top) {
targetY = scrollerRect.top;
}
btnContainer.style.top = `${targetY - parentRect.top}px`;
};
// Find which chunk index the mouse is directly over (deleted or inserted area)
const findHoveredChunkIndex = (event: MouseEvent, view: EditorView): number => {
const el = document.elementFromPoint(event.clientX, event.clientY);
if (!el) return -1;
const deletedChunk = el.closest('.cm-deletedChunk');
if (deletedChunk) {
const all = view.dom.querySelectorAll('.cm-deletedChunk');
return [...all].indexOf(deletedChunk);
}
if (el.closest('.cm-changedLine, .cm-insertedLine')) {
const allChunks = getChunks(view.state);
if (!allChunks) return -1;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos !== null) {
for (let i = 0; i < allChunks.chunks.length; i++) {
const chunk = allChunks.chunks[i];
if (pos >= chunk.fromB && pos <= chunk.toB) return i;
}
}
}
return -1;
};
// Find chunk nearest to cursor Y (for default "below block" display)
const findNearestChunkIndex = (clientY: number, view: EditorView): number => {
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
let result = -1;
if (allChunkEls.length > 0) {
let bestIdx = 0;
let bestDist = Infinity;
allChunkEls.forEach((el, idx) => {
const rect = el.getBoundingClientRect();
const centerY = (rect.top + rect.bottom) / 2;
const dist = Math.abs(clientY - centerY);
if (dist < bestDist) {
bestDist = dist;
bestIdx = idx;
}
});
result = bestIdx;
}
return result;
};
extensions.push(
EditorView.domEventHandlers({
mousemove(event, view) {
const allChunks = getChunks(view.state);
if (allChunks && allChunks.chunks.length > 0) {
const scroller = view.scrollDOM;
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
const hoveredIdx = findHoveredChunkIndex(event, view);
const nearestIdx =
hoveredIdx >= 0 ? hoveredIdx : findNearestChunkIndex(event.clientY, view);
const toolbars = view.dom.querySelectorAll('.cm-merge-toolbar');
toolbars.forEach((tb, idx) => {
tb.classList.toggle('cm-merge-toolbar-active', idx === nearestIdx);
});
if (nearestIdx >= 0 && nearestIdx < allChunkEls.length) {
const chunkEl = allChunkEls[nearestIdx] as HTMLElement;
if (hoveredIdx >= 0) {
positionAtCursor(chunkEl, event.clientY, scroller);
} else {
positionAtBottom(chunkEl, scroller);
}
}
}
return false;
},
mouseleave(_event, view) {
// Keep active toolbar visible, reposition to "below block"
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
if (activeToolbar) {
const chunkEl = activeToolbar.closest('.cm-deletedChunk');
if (chunkEl) positionAtBottom(chunkEl, view.scrollDOM);
}
return false;
},
})
);
// Ensure at least one toolbar is visible (initial load + after accept/reject)
extensions.push(
EditorView.updateListener.of((update) => {
if (update.view.dom.querySelector('.cm-merge-toolbar-active')) return;
requestAnimationFrame(() => {
const v = update.view;
if (v.dom.querySelector('.cm-merge-toolbar-active')) return;
const first = v.dom.querySelector('.cm-merge-toolbar');
if (first) {
first.classList.add('cm-merge-toolbar-active');
const chunkEl = first.closest('.cm-deletedChunk');
if (chunkEl) positionAtBottom(chunkEl, v.scrollDOM);
}
});
})
);
}
extensions.push(unifiedMergeView(mergeConfig));
// Unified merge view (wrapped in compartment for dynamic collapse reconfigure)
extensions.push(
mergeCompartment.current.of(
buildMergeExtension(collapseRef.current.enabled, collapseRef.current.margin)
)
);
return extensions;
}, [
original,
readOnly,
showMergeControls,
collapseUnchangedProp,
collapseMargin,
scrollToNextChunk,
]);
}, [readOnly, showMergeControls, buildMergeExtension]);
useEffect(() => {
if (!containerRef.current) return;
@ -477,6 +729,17 @@ export const CodeMirrorDiffView = ({
};
}, [fileName, buildExtensions, initialState]);
// Dynamic collapse toggle — reconfigure compartment in-place, preserving undo history
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: mergeCompartment.current.reconfigure(
buildMergeExtension(collapseUnchangedProp, collapseMargin)
),
});
}, [collapseUnchangedProp, collapseMargin, buildMergeExtension]);
// Auto-viewed detection via IntersectionObserver
useEffect(() => {
if (!endSentinelRef.current || !onFullyViewed) return;

View file

@ -140,12 +140,7 @@ export const ReviewToolbar = ({
Accept All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>Accept all changes in current file</span>
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
Y
</kbd>
</TooltipContent>
<TooltipContent side="bottom">Accept all changes in current file</TooltipContent>
</Tooltip>
<Tooltip>
@ -158,12 +153,7 @@ export const ReviewToolbar = ({
Reject All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>Reject all changes in current file</span>
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
N
</kbd>
</TooltipContent>
<TooltipContent side="bottom">Reject all changes in current file</TooltipContent>
</Tooltip>
<Tooltip>

View file

@ -1,6 +1,7 @@
import { get, set } from 'idb-keyval';
const IDB_KEY = 'comment-read-state';
const LS_KEY = 'comment-read-state';
const SAVE_DEBOUNCE_MS = 300;
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@ -8,13 +9,14 @@ type ReadState = Record<string, number>; // key = "teamName/taskId", value = tim
let cache: ReadState = {};
let loaded = false;
let idbAvailable = true; // flips to false on first IndexedDB failure
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();
// --- useSyncExternalStore API ---
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
if (!loaded) void loadFromIdb();
if (!loaded) void load();
return () => {
listeners.delete(listener);
};
@ -46,6 +48,28 @@ export function getUnreadCount(
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
}
// --- localStorage fallback ---
function lsLoad(): ReadState | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as ReadState)
: null;
} catch {
return null;
}
}
function lsSave(state: ReadState): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(state));
} catch {
// localStorage full or unavailable — silently ignore
}
}
// --- Internal ---
function hasIndexedDB(): boolean {
return typeof indexedDB !== 'undefined';
@ -59,54 +83,79 @@ function scheduleSave(): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void saveToIdb();
void save();
}, SAVE_DEBOUNCE_MS);
}
async function loadFromIdb(): Promise<void> {
async function load(): Promise<void> {
if (loaded) return;
if (!hasIndexedDB()) {
loaded = true;
return;
}
try {
const stored = await get<ReadState>(IDB_KEY);
if (stored && typeof stored === 'object') {
cache = { ...stored, ...cache }; // merge: in-memory wins over stale IDB
notify();
// Try IndexedDB first
if (hasIndexedDB() && idbAvailable) {
try {
const stored = await get<ReadState>(IDB_KEY);
if (stored && typeof stored === 'object') {
cache = { ...stored, ...cache };
notify();
}
loaded = true;
return;
} catch {
// IndexedDB broken — fall back to localStorage silently
idbAvailable = false;
}
} catch (e) {
console.error('[commentReadStorage] load failed:', e);
}
// Fallback: localStorage
const stored = lsLoad();
if (stored) {
cache = { ...stored, ...cache };
notify();
}
loaded = true;
}
async function saveToIdb(): Promise<void> {
if (!hasIndexedDB()) return;
try {
await set(IDB_KEY, cache);
} catch (e) {
console.error('[commentReadStorage] save failed:', e);
async function save(): Promise<void> {
if (idbAvailable && hasIndexedDB()) {
try {
await set(IDB_KEY, cache);
return;
} catch {
idbAvailable = false;
}
}
lsSave(cache);
}
export async function cleanupStale(): Promise<void> {
if (!hasIndexedDB()) return;
try {
const stored = await get<ReadState>(IDB_KEY);
if (!stored) return;
const now = Date.now();
const cleaned: ReadState = {};
const now = Date.now();
const clean = (state: ReadState): { cleaned: ReadState; changed: boolean } => {
const result: ReadState = {};
let changed = false;
for (const [k, v] of Object.entries(stored)) {
for (const [k, v] of Object.entries(state)) {
if (now - v < STALE_THRESHOLD_MS) {
cleaned[k] = v;
result[k] = v;
} else {
changed = true;
}
}
if (changed) await set(IDB_KEY, cleaned);
} catch (e) {
console.error('[commentReadStorage] cleanup failed:', e);
return { cleaned: result, changed };
};
if (idbAvailable && hasIndexedDB()) {
try {
const stored = await get<ReadState>(IDB_KEY);
if (!stored) return;
const { cleaned, changed } = clean(stored);
if (changed) await set(IDB_KEY, cleaned);
return;
} catch {
idbAvailable = false;
}
}
const stored = lsLoad();
if (!stored) return;
const { cleaned, changed } = clean(stored);
if (changed) lsSave(cleaned);
}

View file

@ -298,6 +298,17 @@ export function initializeNotificationListeners(): () => void {
if (api.teams?.onTeamChange) {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
// Immediate in-memory update for lead activity — no filesystem refresh needed
if (event.type === 'lead-activity' && event.detail) {
useStore.setState((prev) => ({
leadActivityByTeam: {
...prev.leadActivityByTeam,
[event.teamName]: event.detail as 'active' | 'idle' | 'offline',
},
}));
return;
}
// Throttled refresh of summary list (keeps TeamListView current without flooding).
if (!teamListRefreshTimer) {
teamListRefreshTimer = setTimeout(() => {

View file

@ -13,6 +13,7 @@ import type {
CreateTaskRequest,
GlobalTask,
KanbanColumnId,
LeadActivityState,
SendMessageRequest,
SendMessageResult,
TaskComment,
@ -61,6 +62,7 @@ export interface TeamSlice {
lastSendMessageResult: SendMessageResult | null;
reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>;
leadActivityByTeam: Record<string, LeadActivityState>;
activeProvisioningRunId: string | null;
provisioningError: string | null;
kanbanFilterQuery: string | null;
@ -120,6 +122,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
lastSendMessageResult: null,
reviewActionError: null,
provisioningRuns: {},
leadActivityByTeam: {},
activeProvisioningRunId: null,
provisioningError: null,
kanbanFilterQuery: null,

View file

@ -1,4 +1,11 @@
import type { MemberStatus, ResolvedTeamMember, TeamTaskStatus } from '@shared/types';
import { getMemberColor } from '@shared/constants/memberColors';
import type {
LeadActivityState,
MemberStatus,
ResolvedTeamMember,
TeamTaskStatus,
} from '@shared/types';
export function agentAvatarUrl(name: string, size = 64): string {
return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
@ -14,11 +21,17 @@ export const STATUS_DOT_COLORS: Record<MemberStatus, string> = {
export function getMemberDotClass(
member: ResolvedTeamMember,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
if (leadActivity && member.agentType === 'team-lead') {
return leadActivity === 'active'
? `${STATUS_DOT_COLORS.active} animate-pulse`
: STATUS_DOT_COLORS.active;
}
if (member.status === 'unknown') return STATUS_DOT_COLORS.unknown;
if (member.currentTaskId) return STATUS_DOT_COLORS.active;
return member.status === 'active' ? STATUS_DOT_COLORS.active : STATUS_DOT_COLORS.idle;
@ -27,11 +40,15 @@ export function getMemberDotClass(
export function getPresenceLabel(
member: ResolvedTeamMember,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
if (member.status === 'terminated') return 'terminated';
if (isTeamProvisioning) return 'connecting';
if (isTeamAlive === false) return 'offline';
if (leadActivity && member.agentType === 'team-lead') {
return leadActivity === 'active' ? 'processing' : 'ready';
}
if (member.status === 'unknown') return 'idle';
return member.currentTaskId ? 'working' : 'idle';
}
@ -50,6 +67,43 @@ export const TASK_STATUS_LABELS: Record<TeamTaskStatus, string> = {
deleted: 'Deleted',
};
interface MemberColorInput {
name: string;
color?: string;
removedAt?: number | string | null;
agentType?: string;
role?: string;
}
/**
* Build a consistent namecolorName map for all members.
* Replicates the same index-based assignment as MemberList so that
* every component resolves the same color for a given member.
* Also maps "user" to the team-lead's color.
*/
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
const map = new Map<string, string>();
const active = members.filter((m) => !m.removedAt);
const removed = members.filter((m) => m.removedAt);
for (let i = 0; i < active.length; i++) {
map.set(active[i].name, active[i].color ?? getMemberColor(i));
}
for (let i = 0; i < removed.length; i++) {
map.set(removed[i].name, removed[i].color ?? getMemberColor(active.length + i));
}
// Map "user" to team-lead's resolved color
const lead = members.find(
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
);
if (lead) {
map.set('user', map.get(lead.name) ?? getMemberColor(0));
}
return map;
}
export const KANBAN_COLUMN_DISPLAY: Record<
'review' | 'approved',
{ label: string; bg: string; text: string }

View file

@ -3,17 +3,8 @@
* Used during team creation and for preview in the UI.
* Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length].
*/
export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const;
export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'purple', 'red'] as const;
export function getMemberColor(index: number): string {
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
}
/** Derive a stable fallback color from a member name (position-independent). */
export function getMemberColorByName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return MEMBER_COLOR_PALETTE[Math.abs(hash) % MEMBER_COLOR_PALETTE.length];
}

View file

@ -30,6 +30,7 @@ import type {
CreateTaskRequest,
GlobalTask,
KanbanColumnId,
LeadActivityState,
MemberFullStats,
MemberLogSummary,
SendMessageRequest,
@ -425,6 +426,7 @@ export interface TeamsAPI {
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
getProjectBranch: (projectPath: string) => Promise<string | null>;
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void

View file

@ -204,8 +204,10 @@ export interface CreateTaskRequest {
startImmediately?: boolean;
}
export type LeadActivityState = 'active' | 'idle' | 'offline';
export interface TeamChangeEvent {
type: 'config' | 'inbox' | 'task';
type: 'config' | 'inbox' | 'task' | 'lead-activity';
teamName: string;
detail?: string;
}

View file

@ -40,6 +40,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
}));
import {
@ -72,6 +73,7 @@ import {
TEAM_ADD_TASK_COMMENT,
TEAM_GET_ATTACHMENTS,
TEAM_GET_PROJECT_BRANCH,
TEAM_LEAD_ACTIVITY,
TEAM_REMOVE_MEMBER,
TEAM_UPDATE_MEMBER_ROLE,
} from '../../../src/preload/constants/ipcChannels';
@ -134,6 +136,7 @@ describe('ipc teams handlers', () => {
relayLeadInboxMessages: vi.fn(async () => 0),
getLiveLeadProcessMessages: vi.fn(() => []),
getAliveTeams: vi.fn(() => ['my-team']),
getLeadActivityState: vi.fn(() => 'idle'),
stopTeam: vi.fn(() => undefined),
};
@ -174,6 +177,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(true);
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true);
});
it('returns success false on invalid sendMessage args', async () => {
@ -482,5 +486,6 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(false);
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
});
});