diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index fec1ff32..5aedb665 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 getTeamProvisioningService().getAliveTeams()); } +async function handleLeadActivity( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + 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 diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9765db7d..9a620781 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { 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 { 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; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 578c213c..cec2cb83 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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 // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index a65d8b49..c4926af1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_GET_ATTACHMENTS, teamName, messageId); }, + getLeadActivity: async (teamName: string) => { + const result = await invokeIpcWithResult(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); }; }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 88b79bc9..054d16bc 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -761,6 +761,9 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { 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 => { throw new Error('Review is not available in browser mode'); }, - getTaskChanges: async (_teamName: string, _taskId: string) => { + getTaskChanges: async (_teamName: string, _taskId: string): Promise => { throw new Error('Review is not available in browser mode'); }, - getChangeStats: async (_teamName: string, _memberName: string) => { + getChangeStats: async (_teamName: string, _memberName: string): Promise => { throw new Error('Review is not available in browser mode'); }, getFileContent: async ( _teamName: string, _memberName: string | undefined, _filePath: string - ) => { + ): Promise => { throw new Error('Review is not available in browser mode'); }, - applyDecisions: async () => { + applyDecisions: async (): Promise => { throw new Error('Review is not available in browser mode'); }, // Phase 2 stubs - checkConflict: async () => { + checkConflict: async (): Promise => { throw new Error('Review is not available in browser mode'); }, - rejectHunks: async () => { + rejectHunks: async (): Promise => { throw new Error('Review is not available in browser mode'); }, - rejectFile: async () => { + rejectFile: async (): Promise => { throw new Error('Review is not available in browser mode'); }, - previewReject: async () => { + previewReject: async (): Promise => { throw new Error('Review is not available in browser mode'); }, // Editable diff stubs - saveEditedFile: async () => { + saveEditedFile: async (): Promise => { throw new Error('Review is not available in browser mode'); }, // Phase 4 stubs - getGitFileLog: async () => { + getGitFileLog: async (): Promise => { throw new Error('Review is not available in browser mode'); }, }; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 47eb84a6..7df60b1f 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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 ?? ''; diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 46d64c59..cd9b399e 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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 ( + + + {m.name} + + {m.role ? ( + {m.role} + ) : null} + + ); + })} + + ); +} + +function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX.Element | null { + const recentPaths = getRecentProjects(team); + if (recentPaths.length === 0) return null; + return ( +
+ + + {recentPaths.map((p, i) => ( + + {i === 0 && (status === 'active' || status === 'idle') ? ( + {folderName(p)} + ) : ( + folderName(p) + )} + {i < recentPaths.length - 1 ? ', ' : ''} + + ))} + +
+ ); +} + function resolveTeamStatus( teamName: string, aliveTeams: string[], - provisioningRuns: Record + provisioningRuns: Record, + leadActivityByTeam: Record ): 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 ( + Active + + ); + case 'idle': + return ( + + Running ); @@ -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 => { ); - if (teamsLoading) { - return ( -
- {renderHeader()} + const renderContent = (): React.JSX.Element => { + if (teamsLoading) { + return (
Loading teams...
- {createDialogElement} -
- ); - } + ); + } - if (teamsError) { - return ( -
- {renderHeader()} + if (teamsError) { + return (

Failed to load teams

@@ -473,259 +547,212 @@ export const TeamListView = (): React.JSX.Element => {
- {createDialogElement} -
- ); - } + ); + } + + if (teams.length === 0) { + return ; + } + + if (filteredTeams.length === 0 && searchQuery.trim()) { + return ( +
+ No teams matching "{searchQuery.trim()}" +
+ ); + } - if (teams.length === 0) { return ( -
- {renderHeader()} - - {createDialogElement} +
+ {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 ( +
openTeamTab(team.teamName, team.projectPath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openTeamTab(team.teamName, team.projectPath); + } + }} + > + {teamColorSet ? ( +
+ ) : null} +
+
+
+

+ {team.displayName} +

+ +
+
+ {(status === 'active' || status === 'idle') && ( + + + + + + {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} + + + )} + + + + + Copy team + + + + + + Delete team + +
+
+
+

+ {team.description || 'No description'} +

+ {team.projectPath && + (() => { + const branch = branchByPath.get(normalizePath(team.projectPath)); + if (!branch) return null; + return ( + + + {branch} + + ); + })()} +
+
+ {team.members && team.members.length > 0 ? ( + renderMemberChips(team.members) + ) : ( + + Members: {team.memberCount} + + )} + {(() => { + 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 ( +
+
+
+
+
+ + {completed}/{totalTasks} + +
+ {totalTasks > 0 && ( +
+ {inProgress > 0 && ( + + + {inProgress} in_progress + + )} + {pending > 0 && ( + + + {pending} pending + + )} + {completed > 0 && ( + + + {completed} completed + + )} +
+ )} +
+ ); + })()} +
+ {renderTeamRecentPaths(team, status)} +
+
+ ); + })}
); - } + }; return (
{renderHeader()} - - {filteredTeams.length === 0 && searchQuery.trim() ? ( -
- No teams matching "{searchQuery.trim()}" -
- ) : ( -
- {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 ( -
openTeamTab(team.teamName, team.projectPath)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openTeamTab(team.teamName, team.projectPath); - } - }} - > - {teamColorSet ? ( -
- ) : null} -
-
-
-

- {team.displayName} -

- -
-
- {status === 'running' && ( - - - - - - {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} - - - )} - - - - - Copy team - - - - - - Delete team - -
-
-
-

- {team.description || 'No description'} -

- {team.projectPath && - (() => { - const branch = branchByPath.get(normalizePath(team.projectPath)); - if (!branch) return null; - return ( - - - {branch} - - ); - })()} -
-
- {team.members && team.members.length > 0 ? ( - team.members.map((m) => { - const memberColor = m.color ? getTeamColorSet(m.color) : null; - return ( - - - {m.name} - - {m.role ? ( - - {m.role} - - ) : null} - - ); - }) - ) : ( - - Members: {team.memberCount} - - )} - {(() => { - 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 ( -
-
-
-
-
- - {completed}/{totalTasks} - -
- {totalTasks > 0 && ( -
- {inProgress > 0 && ( - - - {inProgress} in_progress - - )} - {pending > 0 && ( - - - {pending} pending - - )} - {completed > 0 && ( - - - {completed} completed - - )} -
- )} -
- ); - })()} -
- {(() => { - const recentPaths = getRecentProjects(team); - if (recentPaths.length === 0) return null; - return ( -
- - - {recentPaths.map((p, i) => ( - - {i === 0 && status === 'running' ? ( - {folderName(p)} - ) : ( - folderName(p) - )} - {i < recentPaths.length - 1 ? ', ' : ''} - - ))} - -
- ); - })()} -
-
- ); - })} -
- )} + {renderContent()} {createDialogElement}
diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index 0f6c455f..0b12c0a2 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -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) ); diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index a663b2c3..5f18fe7e 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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(); const memberInfo = new Map(); 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)) diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 320398a3..6a535c42 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -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

{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) ); diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 56a36f28..db2fd71c 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -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 && Unassigned} {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 ( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 396dc7ad..1d6a2aeb 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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( () => 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; diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 34b3f432..f3c3fbac 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -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( () => @@ -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 => { diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 120bc8b4..25ac2172 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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(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 = ({ Select member... {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 ( diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 339642d9..4b553846 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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( () => @@ -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 = ({ 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 = ({ 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} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 7bab3a80..31b1aef7 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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 = ({ Unassigned {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 ( @@ -174,7 +176,11 @@ export const TaskDetailDialog = ({ ) : currentTask.owner ? ( - + ) : ( )} diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 16a46400..8b2c1200 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -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 = ({ #{task.id} - {task.owner ? ( - m.name === task.owner)?.color} - /> - ) : null} + {task.owner ? : null}
{task.subject}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 88c92bc9..79704020 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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; diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 574495af..d7906a8e 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -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 } diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 4d104ead..0bd274e1 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -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; 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; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 22e5f610..20cb90ff 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -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; 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 = ({ - {activeMembers.map((member, index) => renderCard(member, index, false))} + {activeMembers.map((member) => renderCard(member, false))} {removedMembers.length > 0 && ( <>
Removed ({removedMembers.length})
- {removedMembers.map((member, index) => - renderCard(member, activeMembers.length + index, true) - )} + {removedMembers.map((member) => renderCard(member, true))} )}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 50e4e52e..68e6a32c 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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( () => 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 = ({
{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 ( diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 48b36876..8941bb2d 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -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); diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index 0ab81583..cd3e328b 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -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[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[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('.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('.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; diff --git a/src/renderer/components/team/review/ReviewToolbar.tsx b/src/renderer/components/team/review/ReviewToolbar.tsx index 3fc6ca60..64a348bc 100644 --- a/src/renderer/components/team/review/ReviewToolbar.tsx +++ b/src/renderer/components/team/review/ReviewToolbar.tsx @@ -140,12 +140,7 @@ export const ReviewToolbar = ({ Accept All - - Accept all changes in current file - - ⌘Y - - + Accept all changes in current file @@ -158,12 +153,7 @@ export const ReviewToolbar = ({ Reject All - - Reject all changes in current file - - ⌘N - - + Reject all changes in current file diff --git a/src/renderer/services/commentReadStorage.ts b/src/renderer/services/commentReadStorage.ts index f97becf4..26578d91 100644 --- a/src/renderer/services/commentReadStorage.ts +++ b/src/renderer/services/commentReadStorage.ts @@ -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; // key = "teamName/taskId", value = tim let cache: ReadState = {}; let loaded = false; +let idbAvailable = true; // flips to false on first IndexedDB failure let saveTimer: ReturnType | 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 { +async function load(): Promise { if (loaded) return; - if (!hasIndexedDB()) { - loaded = true; - return; - } - try { - const stored = await get(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(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 { - if (!hasIndexedDB()) return; - try { - await set(IDB_KEY, cache); - } catch (e) { - console.error('[commentReadStorage] save failed:', e); +async function save(): Promise { + if (idbAvailable && hasIndexedDB()) { + try { + await set(IDB_KEY, cache); + return; + } catch { + idbAvailable = false; + } } + lsSave(cache); } export async function cleanupStale(): Promise { - if (!hasIndexedDB()) return; - try { - const stored = await get(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(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); } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index d179a776..9dfc5214 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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(() => { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 84f578ad..ff5dbc6d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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; + leadActivityByTeam: Record; activeProvisioningRunId: string | null; provisioningError: string | null; kanbanFilterQuery: string | null; @@ -120,6 +122,7 @@ export const createTeamSlice: StateCreator = (set, lastSendMessageResult: null, reviewActionError: null, provisioningRuns: {}, + leadActivityByTeam: {}, activeProvisioningRunId: null, provisioningError: null, kanbanFilterQuery: null, diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index fd10157b..2f0579af 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -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 = { 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 = { deleted: 'Deleted', }; +interface MemberColorInput { + name: string; + color?: string; + removedAt?: number | string | null; + agentType?: string; + role?: string; +} + +/** + * Build a consistent name→colorName 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 { + const map = new Map(); + 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 } diff --git a/src/shared/constants/memberColors.ts b/src/shared/constants/memberColors.ts index 901672f0..9830e2ce 100644 --- a/src/shared/constants/memberColors.ts +++ b/src/shared/constants/memberColors.ts @@ -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]; -} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 26ad92aa..bdd0b3ad 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; getProjectBranch: (projectPath: string) => Promise; getAttachments: (teamName: string, messageId: string) => Promise; + getLeadActivity: (teamName: string) => Promise; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e2e2e09d..5363f7ff 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 3af3d400..b1b37d22 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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); }); });