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:
parent
fd3176716b
commit
e4aa544f57
33 changed files with 1013 additions and 536 deletions
|
|
@ -17,6 +17,7 @@ import {
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
TEAM_PROCESS_ALIVE,
|
TEAM_PROCESS_ALIVE,
|
||||||
|
|
@ -193,6 +194,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||||
|
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,6 +231,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||||
|
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamDataService(): TeamDataService {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -1263,6 +1266,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
|
||||||
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
|
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(
|
async function handleStopTeam(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
|
|
|
||||||
|
|
@ -133,8 +133,12 @@ interface ProvisioningRun {
|
||||||
provisioningOutputParts: string[];
|
provisioningOutputParts: string[];
|
||||||
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
||||||
detectedSessionId: string | null;
|
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 =
|
type ProvisioningAuthSource =
|
||||||
| 'anthropic_api_key'
|
| 'anthropic_api_key'
|
||||||
| 'anthropic_auth_token'
|
| 'anthropic_auth_token'
|
||||||
|
|
@ -609,6 +613,24 @@ export class TeamProvisioningService {
|
||||||
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
|
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> {
|
async warmup(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const claudePath = await ClaudeBinaryResolver.resolve();
|
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||||
|
|
@ -768,6 +790,7 @@ export class TeamProvisioningService {
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
detectedSessionId: null,
|
detectedSessionId: null,
|
||||||
|
leadActivityState: 'active',
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -1040,6 +1063,7 @@ export class TeamProvisioningService {
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
detectedSessionId: null,
|
detectedSessionId: null,
|
||||||
|
leadActivityState: 'active',
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -1283,6 +1307,7 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
run.child.stdin.write(payload + '\n');
|
run.child.stdin.write(payload + '\n');
|
||||||
|
this.setLeadActivity(run, 'active');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1740,6 +1765,9 @@ export class TeamProvisioningService {
|
||||||
})();
|
})();
|
||||||
if (subtype === 'success') {
|
if (subtype === 'success') {
|
||||||
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
||||||
|
if (run.provisioningComplete) {
|
||||||
|
this.setLeadActivity(run, 'idle');
|
||||||
|
}
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
const capture = run.leadRelayCapture;
|
const capture = run.leadRelayCapture;
|
||||||
const combined = capture.textParts.join('').trim();
|
const combined = capture.textParts.join('').trim();
|
||||||
|
|
@ -1800,6 +1828,9 @@ export class TeamProvisioningService {
|
||||||
run.child?.stdin?.end();
|
run.child?.stdin?.end();
|
||||||
run.child?.kill();
|
run.child?.kill();
|
||||||
this.cleanupRun(run);
|
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> {
|
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||||
if (run.cancelRequested) return;
|
if (run.cancelRequested) return;
|
||||||
run.provisioningComplete = true;
|
run.provisioningComplete = true;
|
||||||
|
this.setLeadActivity(run, 'idle');
|
||||||
|
|
||||||
// Clear provisioning timeout — no longer needed
|
// Clear provisioning timeout — no longer needed
|
||||||
if (run.timeoutHandle) {
|
if (run.timeoutHandle) {
|
||||||
|
|
@ -1880,6 +1912,7 @@ export class TeamProvisioningService {
|
||||||
* Remove a run from tracking maps.
|
* Remove a run from tracking maps.
|
||||||
*/
|
*/
|
||||||
private cleanupRun(run: ProvisioningRun): void {
|
private cleanupRun(run: ProvisioningRun): void {
|
||||||
|
this.setLeadActivity(run, 'offline');
|
||||||
if (run.timeoutHandle) {
|
if (run.timeoutHandle) {
|
||||||
clearTimeout(run.timeoutHandle);
|
clearTimeout(run.timeoutHandle);
|
||||||
run.timeoutHandle = null;
|
run.timeoutHandle = null;
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,9 @@ export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole';
|
||||||
/** Get attachment data for a message */
|
/** Get attachment data for a message */
|
||||||
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
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
|
// Review API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
TEAM_PROCESS_ALIVE,
|
TEAM_PROCESS_ALIVE,
|
||||||
|
|
@ -663,6 +664,10 @@ const electronAPI: ElectronAPI = {
|
||||||
getAttachments: async (teamName: string, messageId: string) => {
|
getAttachments: async (teamName: string, messageId: string) => {
|
||||||
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
|
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) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
|
|
@ -761,9 +766,9 @@ const electronAPI: ElectronAPI = {
|
||||||
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
|
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
|
||||||
},
|
},
|
||||||
onCmdN: (callback: () => void): (() => void) => {
|
onCmdN: (callback: () => void): (() => void) => {
|
||||||
const handler = () => callback();
|
const handler = (): void => callback();
|
||||||
ipcRenderer.on('review:cmdN', handler);
|
ipcRenderer.on('review:cmdN', handler);
|
||||||
return () => {
|
return (): void => {
|
||||||
ipcRenderer.removeListener('review:cmdN', handler);
|
ipcRenderer.removeListener('review:cmdN', handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -761,6 +761,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<AttachmentFileData[]> => {
|
): Promise<AttachmentFileData[]> => {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||||
|
return 'offline';
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
return this.addEventListener('team-change', (data: unknown) =>
|
return this.addEventListener('team-change', (data: unknown) =>
|
||||||
callback(null, data as TeamChangeEvent)
|
callback(null, data as TeamChangeEvent)
|
||||||
|
|
@ -775,44 +778,44 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
|
|
||||||
// Review API stubs
|
// Review API stubs
|
||||||
review = {
|
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');
|
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');
|
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');
|
throw new Error('Review is not available in browser mode');
|
||||||
},
|
},
|
||||||
getFileContent: async (
|
getFileContent: async (
|
||||||
_teamName: string,
|
_teamName: string,
|
||||||
_memberName: string | undefined,
|
_memberName: string | undefined,
|
||||||
_filePath: string
|
_filePath: string
|
||||||
) => {
|
): Promise<never> => {
|
||||||
throw new Error('Review is not available in browser mode');
|
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');
|
throw new Error('Review is not available in browser mode');
|
||||||
},
|
},
|
||||||
// Phase 2 stubs
|
// Phase 2 stubs
|
||||||
checkConflict: async () => {
|
checkConflict: async (): Promise<never> => {
|
||||||
throw new Error('Review is not available in browser mode');
|
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');
|
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');
|
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');
|
throw new Error('Review is not available in browser mode');
|
||||||
},
|
},
|
||||||
// Editable diff stubs
|
// Editable diff stubs
|
||||||
saveEditedFile: async () => {
|
saveEditedFile: async (): Promise<never> => {
|
||||||
throw new Error('Review is not available in browser mode');
|
throw new Error('Review is not available in browser mode');
|
||||||
},
|
},
|
||||||
// Phase 4 stubs
|
// Phase 4 stubs
|
||||||
getGitFileLog: async () => {
|
getGitFileLog: async (): Promise<never> => {
|
||||||
throw new Error('Review is not available in browser mode');
|
throw new Error('Review is not available in browser mode');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
launchTeam,
|
launchTeam,
|
||||||
provisioningError,
|
provisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
leadActivityByTeam,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
|
|
@ -201,6 +202,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||||||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||||
),
|
),
|
||||||
|
leadActivityByTeam: s.leadActivityByTeam,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
|
|
@ -792,6 +794,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
leadActivity={leadActivityByTeam[teamName]}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onSendMessage={(member) => {
|
onSendMessage={(member) => {
|
||||||
setSendDialogRecipient(member.name);
|
setSendDialogRecipient(member.name);
|
||||||
|
|
@ -1125,6 +1128,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
messages={data.messages}
|
messages={data.messages}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
leadActivity={leadActivityByTeam[teamName]}
|
||||||
onClose={() => setSelectedMember(null)}
|
onClose={() => setSelectedMember(null)}
|
||||||
onSendMessage={() => {
|
onSendMessage={() => {
|
||||||
const name = selectedMember?.name ?? '';
|
const name = selectedMember?.name ?? '';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
|
@ -12,6 +13,7 @@ import {
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,7 +33,12 @@ import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||||
import { TeamEmptyState } from './TeamEmptyState';
|
import { TeamEmptyState } from './TeamEmptyState';
|
||||||
|
|
||||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
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 {
|
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
||||||
const base = sourceName.replace(/-\d+$/, '');
|
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[] {
|
function getRecentProjects(team: TeamSummary): string[] {
|
||||||
const history = team.projectPathHistory;
|
const history = team.projectPathHistory;
|
||||||
|
|
@ -58,13 +65,69 @@ function folderName(fullPath: string): string {
|
||||||
return getBaseName(fullPath) || fullPath;
|
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(
|
function resolveTeamStatus(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
aliveTeams: string[],
|
aliveTeams: string[],
|
||||||
provisioningRuns: Record<string, TeamProvisioningProgress>
|
provisioningRuns: Record<string, TeamProvisioningProgress>,
|
||||||
|
leadActivityByTeam: Record<string, string>
|
||||||
): TeamStatus {
|
): TeamStatus {
|
||||||
if (aliveTeams.includes(teamName)) {
|
if (aliveTeams.includes(teamName)) {
|
||||||
return 'running';
|
return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle';
|
||||||
}
|
}
|
||||||
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
||||||
for (const run of Object.values(provisioningRuns)) {
|
for (const run of Object.values(provisioningRuns)) {
|
||||||
|
|
@ -77,10 +140,17 @@ function resolveTeamStatus(
|
||||||
|
|
||||||
const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'active':
|
||||||
return (
|
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="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" />
|
<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
|
Running
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -141,14 +211,16 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
activeProjectId: s.activeProjectId,
|
activeProjectId: s.activeProjectId,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
|
const { connectionMode, createTeam, provisioningError, provisioningRuns, leadActivityByTeam } =
|
||||||
useShallow((s) => ({
|
useStore(
|
||||||
connectionMode: s.connectionMode,
|
useShallow((s) => ({
|
||||||
createTeam: s.createTeam,
|
connectionMode: s.connectionMode,
|
||||||
provisioningError: s.provisioningError,
|
createTeam: s.createTeam,
|
||||||
provisioningRuns: s.provisioningRuns,
|
provisioningError: s.provisioningError,
|
||||||
}))
|
provisioningRuns: s.provisioningRuns,
|
||||||
);
|
leadActivityByTeam: s.leadActivityByTeam,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const canCreate = electronMode && connectionMode === 'local';
|
const canCreate = electronMode && connectionMode === 'local';
|
||||||
|
|
||||||
// Fetch alive teams on mount and when teams list changes
|
// Fetch alive teams on mount and when teams list changes
|
||||||
|
|
@ -275,11 +347,18 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
const handleDeleteTeam = useCallback(
|
const handleDeleteTeam = useCallback(
|
||||||
(teamName: string, e: React.MouseEvent) => {
|
(teamName: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const confirmed = window.confirm(`Delete team "${teamName}"? This action is irreversible.`);
|
void (async () => {
|
||||||
if (!confirmed) {
|
const confirmed = await confirm({
|
||||||
return;
|
title: 'Delete team',
|
||||||
}
|
message: `Delete team "${teamName}"? This action is irreversible.`,
|
||||||
void deleteTeam(teamName);
|
confirmLabel: 'Delete',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
variant: 'danger',
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
void deleteTeam(teamName);
|
||||||
|
}
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
[deleteTeam]
|
[deleteTeam]
|
||||||
);
|
);
|
||||||
|
|
@ -441,22 +520,17 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (teamsLoading) {
|
const renderContent = (): React.JSX.Element => {
|
||||||
return (
|
if (teamsLoading) {
|
||||||
<div className="size-full overflow-auto p-4">
|
return (
|
||||||
{renderHeader()}
|
|
||||||
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
|
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
|
||||||
Loading teams...
|
Loading teams...
|
||||||
</div>
|
</div>
|
||||||
{createDialogElement}
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (teamsError) {
|
if (teamsError) {
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-auto p-4">
|
|
||||||
{renderHeader()}
|
|
||||||
<div className="flex size-full items-center justify-center p-6">
|
<div className="flex size-full items-center justify-center p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-medium text-red-400">Failed to load teams</p>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 "{searchQuery.trim()}"
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (teams.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-auto p-4">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{renderHeader()}
|
{filteredTeams.map((team) => {
|
||||||
<TeamEmptyState />
|
const status = resolveTeamStatus(
|
||||||
{createDialogElement}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="size-full overflow-auto p-4">
|
<div className="size-full overflow-auto p-4">
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
|
{renderContent()}
|
||||||
{filteredTeams.length === 0 && searchQuery.trim() ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
|
|
||||||
No teams matching "{searchQuery.trim()}"
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
{createDialogElement}
|
{createDialogElement}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -18,6 +19,7 @@ export const ActiveTasksBlock = ({
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
}: ActiveTasksBlockProps): React.JSX.Element | null => {
|
}: ActiveTasksBlockProps): React.JSX.Element | null => {
|
||||||
|
const colorMap = buildMemberColorMap(members);
|
||||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
||||||
const working = members.filter((m) => m.currentTaskId != null);
|
const working = members.filter((m) => m.currentTaskId != null);
|
||||||
if (working.length === 0) return null;
|
if (working.length === 0) return null;
|
||||||
|
|
@ -30,7 +32,7 @@ export const ActiveTasksBlock = ({
|
||||||
{working.map((member) => {
|
{working.map((member) => {
|
||||||
const taskId = member.currentTaskId!;
|
const taskId = member.currentTaskId!;
|
||||||
const task = taskMap.get(taskId);
|
const task = taskMap.get(taskId);
|
||||||
const colors = getTeamColorSet(member.color ?? '');
|
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
|
||||||
const roleLabel = formatAgentRole(
|
const roleLabel = formatAgentRole(
|
||||||
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import { ActivityItem } from './ActivityItem';
|
import { ActivityItem } from './ActivityItem';
|
||||||
|
|
||||||
|
|
@ -104,35 +104,27 @@ export const ActivityTimeline = ({
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onMessageVisible,
|
onMessageVisible,
|
||||||
}: ActivityTimelineProps): React.JSX.Element => {
|
}: ActivityTimelineProps): React.JSX.Element => {
|
||||||
|
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
||||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||||
if (members) {
|
if (members) {
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
const info = {
|
const info = {
|
||||||
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
|
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
|
||||||
color: m.color,
|
color: colorMap.get(m.name),
|
||||||
};
|
};
|
||||||
memberInfo.set(m.name, info);
|
memberInfo.set(m.name, info);
|
||||||
if (m.agentType && m.agentType !== m.name) {
|
if (m.agentType && m.agentType !== m.name) {
|
||||||
memberInfo.set(m.agentType, info);
|
memberInfo.set(m.agentType, info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Map "user" to team-lead's resolved color and role
|
||||||
const leadMember = members.find(
|
const leadMember = members.find(
|
||||||
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
|
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
|
||||||
);
|
);
|
||||||
if (leadMember) {
|
if (leadMember) {
|
||||||
const leadInfo = memberInfo.get(leadMember.name);
|
const leadInfo = memberInfo.get(leadMember.name);
|
||||||
if (leadInfo) {
|
if (leadInfo) {
|
||||||
const teamLeadColor = leadInfo.color ?? getMemberColorByName('team-lead');
|
memberInfo.set('user', { role: leadInfo.role, color: colorMap.get('user') });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +149,7 @@ export const ActivityTimeline = ({
|
||||||
const info = memberInfo.get(message.from);
|
const info = memberInfo.get(message.from);
|
||||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||||
const recipientColor =
|
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 messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
||||||
const isUnread = readState
|
const isUnread = readState
|
||||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ export const PendingRepliesBlock = ({
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||||
|
const colorMap = buildMemberColorMap(members);
|
||||||
const pending = Object.entries(pendingRepliesByMember)
|
const pending = Object.entries(pendingRepliesByMember)
|
||||||
.map(([name, sentAtMs]) => ({
|
.map(([name, sentAtMs]) => ({
|
||||||
member: members.find((m) => m.name === name) ?? null,
|
member: members.find((m) => m.name === name) ?? null,
|
||||||
|
|
@ -34,7 +36,7 @@ export const PendingRepliesBlock = ({
|
||||||
Awaiting replies
|
Awaiting replies
|
||||||
</p>
|
</p>
|
||||||
{pending.map(({ member, sentAtMs }) => {
|
{pending.map(({ member, sentAtMs }) => {
|
||||||
const colors = getTeamColorSet(member.color ?? '');
|
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
|
||||||
const roleLabel = formatAgentRole(
|
const roleLabel = formatAgentRole(
|
||||||
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { AlertTriangle, Search } from 'lucide-react';
|
import { AlertTriangle, Search } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -66,6 +67,7 @@ export const CreateTaskDialog = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting = false,
|
submitting = false,
|
||||||
}: CreateTaskDialogProps): React.JSX.Element => {
|
}: CreateTaskDialogProps): React.JSX.Element => {
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const [subject, setSubject] = useState(defaultSubject);
|
const [subject, setSubject] = useState(defaultSubject);
|
||||||
const descriptionDraft = useDraftPersistence({
|
const descriptionDraft = useDraftPersistence({
|
||||||
key: `createTask:${teamName}:description`,
|
key: `createTask:${teamName}:description`,
|
||||||
|
|
@ -103,9 +105,9 @@ export const CreateTaskDialog = ({
|
||||||
id: m.name,
|
id: m.name,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
color: m.color,
|
color: colorMap.get(m.name),
|
||||||
})),
|
})),
|
||||||
[members]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const requiresOwner = defaultStartImmediately === true;
|
const requiresOwner = defaultStartImmediately === true;
|
||||||
|
|
@ -161,7 +163,8 @@ export const CreateTaskDialog = ({
|
||||||
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
|
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
|
||||||
{members.map((m) => {
|
{members.map((m) => {
|
||||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
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 (
|
return (
|
||||||
<SelectItem key={m.name} value={m.name}>
|
<SelectItem key={m.name} value={m.name}>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -248,15 +249,16 @@ export const LaunchTeamDialog = ({
|
||||||
);
|
);
|
||||||
}, [activeTeams, effectiveCwd, teamName]);
|
}, [activeTeams, effectiveCwd, teamName]);
|
||||||
|
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
members.map((m) => ({
|
members.map((m) => ({
|
||||||
id: m.name,
|
id: m.name,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
color: m.color,
|
color: colorMap.get(m.name),
|
||||||
})),
|
})),
|
||||||
[members]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeError = localError ?? provisioningError;
|
const activeError = localError ?? provisioningError;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
@ -38,6 +39,7 @@ export const ReviewDialog = ({
|
||||||
key: `requestChanges:${teamName}:${taskId ?? ''}`,
|
key: `requestChanges:${teamName}:${taskId ?? ''}`,
|
||||||
enabled: Boolean(teamName && taskId),
|
enabled: Boolean(teamName && taskId),
|
||||||
});
|
});
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -45,9 +47,9 @@ export const ReviewDialog = ({
|
||||||
id: m.name,
|
id: m.name,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
color: m.color,
|
color: colorMap.get(m.name),
|
||||||
})),
|
})),
|
||||||
[members]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancel = (): void => {
|
const handleCancel = (): void => {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -59,6 +60,7 @@ export const SendMessageDialog = ({
|
||||||
onSend,
|
onSend,
|
||||||
onClose,
|
onClose,
|
||||||
}: SendMessageDialogProps): React.JSX.Element => {
|
}: SendMessageDialogProps): React.JSX.Element => {
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||||
const [member, setMember] = useState('');
|
const [member, setMember] = useState('');
|
||||||
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
||||||
|
|
@ -102,9 +104,9 @@ export const SendMessageDialog = ({
|
||||||
id: m.name,
|
id: m.name,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
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;
|
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>
|
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
|
||||||
{members.map((m) => {
|
{members.map((m) => {
|
||||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
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 (
|
return (
|
||||||
<SelectItem key={m.name} value={m.name}>
|
<SelectItem key={m.name} value={m.name}>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
||||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
|
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 draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -59,9 +62,9 @@ export const TaskCommentsSection = ({
|
||||||
id: m.name,
|
id: m.name,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
color: m.color,
|
color: colorMap.get(m.name),
|
||||||
})),
|
})),
|
||||||
[members]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const trimmed = draft.value.trim();
|
const trimmed = draft.value.trim();
|
||||||
|
|
@ -102,11 +105,10 @@ export const TaskCommentsSection = ({
|
||||||
<span
|
<span
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
style={{
|
style={{
|
||||||
color:
|
color: (() => {
|
||||||
comment.author === 'user'
|
const rc = colorMap.get(comment.author);
|
||||||
? 'var(--color-text-secondary)'
|
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
|
||||||
: (members.find((m) => m.name === comment.author)?.color ??
|
})(),
|
||||||
'var(--color-text-secondary)'),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{comment.author}
|
{comment.author}
|
||||||
|
|
@ -228,11 +230,10 @@ export const TaskCommentsSection = ({
|
||||||
<span
|
<span
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
style={{
|
style={{
|
||||||
color:
|
color: (() => {
|
||||||
replyTo.author === 'user'
|
const rc = colorMap.get(replyTo.author);
|
||||||
? 'var(--color-text-secondary)'
|
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
|
||||||
: (members.find((m) => m.name === replyTo.author)?.color ??
|
})(),
|
||||||
'var(--color-text-secondary)'),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@{replyTo.author}
|
@{replyTo.author}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||||
|
|
@ -25,6 +25,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
|
buildMemberColorMap,
|
||||||
KANBAN_COLUMN_DISPLAY,
|
KANBAN_COLUMN_DISPLAY,
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
|
|
@ -59,6 +60,7 @@ export const TaskDetailDialog = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onOwnerChange,
|
onOwnerChange,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -110,7 +112,6 @@ export const TaskDetailDialog = ({
|
||||||
t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id)
|
t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id)
|
||||||
)
|
)
|
||||||
.map((t) => t.id);
|
.map((t) => t.id);
|
||||||
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
|
|
||||||
const isTodo = status === 'pending' && !kanbanColumn;
|
const isTodo = status === 'pending' && !kanbanColumn;
|
||||||
const canReassign = isTodo && onOwnerChange;
|
const canReassign = isTodo && onOwnerChange;
|
||||||
|
|
||||||
|
|
@ -151,7 +152,8 @@ export const TaskDetailDialog = ({
|
||||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||||
{members.map((m) => {
|
{members.map((m) => {
|
||||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
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 (
|
return (
|
||||||
<SelectItem key={m.name} value={m.name}>
|
<SelectItem key={m.name} value={m.name}>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
|
@ -174,7 +176,11 @@ export const TaskDetailDialog = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : currentTask.owner ? (
|
) : 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)]">—</span>
|
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
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 { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import {
|
import {
|
||||||
ArrowLeftFromLine,
|
ArrowLeftFromLine,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
|
|
@ -143,6 +144,7 @@ export const KanbanTaskCard = ({
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
}: KanbanTaskCardProps): React.JSX.Element => {
|
}: KanbanTaskCardProps): React.JSX.Element => {
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
||||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = task.blocks?.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">
|
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
|
||||||
#{task.id}
|
#{task.id}
|
||||||
</Badge>
|
</Badge>
|
||||||
{task.owner ? (
|
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
||||||
<MemberBadge
|
|
||||||
name={task.owner}
|
|
||||||
color={members.find((m) => m.name === task.owner)?.color}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
||||||
{task.subject}
|
{task.subject}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/u
|
||||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberCardProps {
|
interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
|
|
@ -14,6 +14,7 @@ interface MemberCardProps {
|
||||||
taskCounts?: TaskStatusCounts | null;
|
taskCounts?: TaskStatusCounts | null;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
leadActivity?: LeadActivityState;
|
||||||
currentTask?: TeamTaskWithKanban | null;
|
currentTask?: TeamTaskWithKanban | null;
|
||||||
isAwaitingReply?: boolean;
|
isAwaitingReply?: boolean;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
|
|
@ -29,6 +30,7 @@ export const MemberCard = ({
|
||||||
taskCounts,
|
taskCounts,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
leadActivity,
|
||||||
currentTask,
|
currentTask,
|
||||||
isAwaitingReply,
|
isAwaitingReply,
|
||||||
isRemoved,
|
isRemoved,
|
||||||
|
|
@ -37,8 +39,8 @@ export const MemberCard = ({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element => {
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
const colors = getTeamColorSet(memberColor);
|
const colors = getTeamColorSet(memberColor);
|
||||||
const pending = taskCounts?.pending ?? 0;
|
const pending = taskCounts?.pending ?? 0;
|
||||||
const inProgress = taskCounts?.inProgress ?? 0;
|
const inProgress = taskCounts?.inProgress ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ import { MemberMessagesTab } from './MemberMessagesTab';
|
||||||
import { MemberStatsTab } from './MemberStatsTab';
|
import { MemberStatsTab } from './MemberStatsTab';
|
||||||
import { MemberTasksTab } from './MemberTasksTab';
|
import { MemberTasksTab } from './MemberTasksTab';
|
||||||
|
|
||||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type {
|
||||||
|
InboxMessage,
|
||||||
|
LeadActivityState,
|
||||||
|
ResolvedTeamMember,
|
||||||
|
TeamTaskWithKanban,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
interface MemberDetailDialogProps {
|
interface MemberDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -22,6 +27,7 @@ interface MemberDetailDialogProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
leadActivity?: LeadActivityState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendMessage: () => void;
|
onSendMessage: () => void;
|
||||||
onAssignTask: () => void;
|
onAssignTask: () => void;
|
||||||
|
|
@ -39,6 +45,7 @@ export const MemberDetailDialog = ({
|
||||||
messages,
|
messages,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
leadActivity,
|
||||||
onClose,
|
onClose,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -80,6 +87,7 @@ export const MemberDetailDialog = ({
|
||||||
member={member}
|
member={member}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
||||||
onUpdateRole={
|
onUpdateRole={
|
||||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ import { Pencil } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberRoleEditor } from './MemberRoleEditor';
|
import { MemberRoleEditor } from './MemberRoleEditor';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { LeadActivityState, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
interface MemberDetailHeaderProps {
|
interface MemberDetailHeaderProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
leadActivity?: LeadActivityState;
|
||||||
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||||
updatingRole?: boolean;
|
updatingRole?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -22,14 +23,15 @@ export const MemberDetailHeader = ({
|
||||||
member,
|
member,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
leadActivity,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
updatingRole,
|
updatingRole,
|
||||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
const role = member.role || formatAgentRole(member.agentType);
|
const role = member.role || formatAgentRole(member.agentType);
|
||||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
|
|
||||||
const canEditRole =
|
const canEditRole =
|
||||||
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberListProps {
|
interface MemberListProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
|
|
@ -12,6 +12,7 @@ interface MemberListProps {
|
||||||
pendingRepliesByMember?: Record<string, number>;
|
pendingRepliesByMember?: Record<string, number>;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
leadActivity?: LeadActivityState;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||||
|
|
@ -25,6 +26,7 @@ export const MemberList = ({
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
leadActivity,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -32,6 +34,7 @@ export const MemberList = ({
|
||||||
}: MemberListProps): React.JSX.Element => {
|
}: MemberListProps): React.JSX.Element => {
|
||||||
const activeMembers = members.filter((m) => !m.removedAt);
|
const activeMembers = members.filter((m) => !m.removedAt);
|
||||||
const removedMembers = members.filter((m) => m.removedAt);
|
const removedMembers = members.filter((m) => m.removedAt);
|
||||||
|
const colorMap = buildMemberColorMap(members);
|
||||||
|
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,11 +44,7 @@ export const MemberList = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCard = (
|
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
||||||
member: ResolvedTeamMember,
|
|
||||||
index: number,
|
|
||||||
isRemoved: boolean
|
|
||||||
): React.JSX.Element => {
|
|
||||||
const currentTask =
|
const currentTask =
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||||
|
|
@ -53,10 +52,11 @@ export const MemberList = ({
|
||||||
<MemberCard
|
<MemberCard
|
||||||
key={member.name}
|
key={member.name}
|
||||||
member={member}
|
member={member}
|
||||||
memberColor={member.color ?? getMemberColor(index)}
|
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
||||||
currentTask={isRemoved ? null : currentTask}
|
currentTask={isRemoved ? null : currentTask}
|
||||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||||
isRemoved={isRemoved}
|
isRemoved={isRemoved}
|
||||||
|
|
@ -70,15 +70,13 @@ export const MemberList = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<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 && (
|
{removedMembers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
Removed ({removedMembers.length})
|
Removed ({removedMembers.length})
|
||||||
</div>
|
</div>
|
||||||
{removedMembers.map((member, index) =>
|
{removedMembers.map((member) => renderCard(member, true))}
|
||||||
renderCard(member, activeMembers.length + index, true)
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Send } from 'lucide-react';
|
import { AlertCircle, Check, ChevronDown, ImagePlus, Send } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -61,15 +62,17 @@ export const MessageComposer = ({
|
||||||
handleDrop,
|
handleDrop,
|
||||||
} = useAttachments();
|
} = useAttachments();
|
||||||
|
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
members.map((m) => ({
|
members.map((m) => ({
|
||||||
id: m.name,
|
id: m.name,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
color: m.color,
|
color: colorMap.get(m.name),
|
||||||
})),
|
})),
|
||||||
[members]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const trimmed = draft.value.trim();
|
const trimmed = draft.value.trim();
|
||||||
|
|
@ -77,7 +80,8 @@ export const MessageComposer = ({
|
||||||
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
|
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
|
||||||
|
|
||||||
const selectedMember = members.find((m) => m.name === recipient);
|
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 isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||||
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
||||||
|
|
||||||
|
|
@ -201,7 +205,8 @@ export const MessageComposer = ({
|
||||||
<PopoverContent align="start" className="w-56 p-1.5">
|
<PopoverContent align="start" className="w-56 p-1.5">
|
||||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||||
{members.map((m) => {
|
{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 role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||||
const isSelected = m.name === recipient;
|
const isSelected = m.name === recipient;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ export const ChangeReviewDialog = ({
|
||||||
setHunkDecision,
|
setHunkDecision,
|
||||||
setCollapseUnchanged,
|
setCollapseUnchanged,
|
||||||
fetchFileContent,
|
fetchFileContent,
|
||||||
acceptAll,
|
acceptAllFile,
|
||||||
rejectAll,
|
rejectAllFile,
|
||||||
applyReview,
|
applyReview,
|
||||||
// Editable diff
|
// Editable diff
|
||||||
editedContents,
|
editedContents,
|
||||||
|
|
@ -113,15 +113,6 @@ export const ChangeReviewDialog = ({
|
||||||
progress: viewedProgress,
|
progress: viewedProgress,
|
||||||
} = useViewedFiles(teamName, scopeKey, allFilePaths);
|
} = 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
|
// Editable diff computed values
|
||||||
const editedCount = Object.keys(editedContents).length;
|
const editedCount = Object.keys(editedContents).length;
|
||||||
const hasCurrentFileEdits = !!(
|
const hasCurrentFileEdits = !!(
|
||||||
|
|
@ -144,14 +135,14 @@ export const ChangeReviewDialog = ({
|
||||||
const handleAcceptAll = useCallback(() => {
|
const handleAcceptAll = useCallback(() => {
|
||||||
const view = editorViewRef.current;
|
const view = editorViewRef.current;
|
||||||
if (view) acceptAllChunks(view);
|
if (view) acceptAllChunks(view);
|
||||||
acceptAll();
|
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
|
||||||
}, [acceptAll]);
|
}, [selectedReviewFilePath, acceptAllFile]);
|
||||||
|
|
||||||
const handleRejectAll = useCallback(() => {
|
const handleRejectAll = useCallback(() => {
|
||||||
const view = editorViewRef.current;
|
const view = editorViewRef.current;
|
||||||
if (view) rejectAllChunks(view);
|
if (view) rejectAllChunks(view);
|
||||||
rejectAll();
|
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
|
||||||
}, [rejectAll]);
|
}, [selectedReviewFilePath, rejectAllFile]);
|
||||||
|
|
||||||
const handleSaveCurrentFile = useCallback(() => {
|
const handleSaveCurrentFile = useCallback(() => {
|
||||||
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { languages } from '@codemirror/language-data';
|
||||||
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
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';
|
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
|
||||||
|
|
||||||
|
|
@ -147,7 +147,14 @@ const diffTheme = EditorView.theme({
|
||||||
backgroundColor: 'var(--color-surface)',
|
backgroundColor: 'var(--color-surface)',
|
||||||
borderRight: '1px solid var(--color-border)',
|
borderRight: '1px solid var(--color-border)',
|
||||||
color: 'var(--color-text-muted)',
|
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': {
|
'.cm-activeLineGutter': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
|
|
@ -167,51 +174,79 @@ const diffTheme = EditorView.theme({
|
||||||
'.cm-selectionBackground': {
|
'.cm-selectionBackground': {
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||||
},
|
},
|
||||||
// Diff-specific styles — line-level backgrounds (no per-character underlines)
|
// Diff-specific line/block backgrounds
|
||||||
'.cm-changedLine': {
|
'.cm-changedLine': { backgroundColor: '#1a3a1a !important' },
|
||||||
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
|
'.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': {
|
'.cm-merge-toolbar': {
|
||||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
display: 'none',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
'&.cm-merge-toolbar-active': {
|
||||||
|
display: 'flex',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'.cm-insertedLine': {
|
'.cm-merge-nav': {
|
||||||
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
|
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': {
|
'.cm-merge-nav-btn': {
|
||||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
border: 'none',
|
||||||
},
|
background: 'transparent',
|
||||||
// Merge control buttons
|
color: 'var(--color-text-secondary)',
|
||||||
'.cm-merge-accept': {
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '0 4px',
|
padding: '3px 8px',
|
||||||
margin: '0 2px',
|
fontSize: '13px',
|
||||||
borderRadius: '3px',
|
lineHeight: '20px',
|
||||||
fontSize: '11px',
|
'&: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',
|
fontWeight: '500',
|
||||||
lineHeight: '18px',
|
lineHeight: '20px',
|
||||||
display: 'inline-block',
|
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',
|
color: '#3fb950',
|
||||||
backgroundColor: 'rgba(46, 160, 67, 0.15)',
|
backgroundColor: 'rgba(46, 160, 67, 0.25)',
|
||||||
border: '1px solid rgba(46, 160, 67, 0.3)',
|
border: '1px solid rgba(46, 160, 67, 0.4)',
|
||||||
'&:hover': {
|
'&:hover': { backgroundColor: 'rgba(46, 160, 67, 0.4)' },
|
||||||
backgroundColor: 'rgba(46, 160, 67, 0.3)',
|
'& kbd': { fontSize: '10px', color: 'rgba(63, 185, 80, 0.7)', marginLeft: '4px' },
|
||||||
},
|
|
||||||
},
|
|
||||||
'.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)',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Collapse unchanged region marker
|
// Collapse unchanged region marker
|
||||||
'.cm-collapsedLines': {
|
'.cm-collapsedLines': {
|
||||||
|
|
@ -268,10 +303,152 @@ export const CodeMirrorDiffView = ({
|
||||||
|
|
||||||
// Compartment for lazy-injected language support
|
// Compartment for lazy-injected language support
|
||||||
const langCompartment = useRef(new Compartment());
|
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 buildExtensions = useCallback(() => {
|
||||||
const extensions: Extension[] = [
|
const extensions: Extension[] = [
|
||||||
diffTheme,
|
diffTheme,
|
||||||
|
lineNumbers(),
|
||||||
syntaxHighlighting(oneDarkHighlightStyle),
|
syntaxHighlighting(oneDarkHighlightStyle),
|
||||||
EditorView.editable.of(!readOnly),
|
EditorView.editable.of(!readOnly),
|
||||||
EditorState.readOnly.of(readOnly),
|
EditorState.readOnly.of(readOnly),
|
||||||
|
|
@ -339,77 +516,152 @@ export const CodeMirrorDiffView = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified merge view
|
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||||
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
|
||||||
original,
|
|
||||||
highlightChanges: false,
|
|
||||||
gutter: true,
|
|
||||||
syntaxHighlightDeletions: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (collapseUnchangedProp) {
|
|
||||||
mergeConfig.collapseUnchanged = {
|
|
||||||
margin: collapseMargin,
|
|
||||||
minSize: 4,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showMergeControls) {
|
if (showMergeControls) {
|
||||||
// NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge.
|
// Helper: position a chunkButtons container so it's below the change block,
|
||||||
// CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes.
|
// but clamped to the visible viewport if that would be off-screen.
|
||||||
// When EditorView is recreated (e.g. from cached initialState), toDOM() returns
|
const positionAtBottom = (chunkEl: Element, scroller: Element): void => {
|
||||||
// the OLD cached DOM whose `action` closure references the DESTROYED view.
|
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
||||||
// Instead, we call acceptChunk/rejectChunk directly with viewRef.current.
|
if (!btnContainer) return;
|
||||||
mergeConfig.mergeControls = (type, _action) => {
|
const parentRect = chunkEl.getBoundingClientRect();
|
||||||
const btn = document.createElement('button');
|
const scrollerRect = scroller.getBoundingClientRect();
|
||||||
|
// "below block" = 100% of parent height
|
||||||
if (type === 'accept') {
|
let targetY = parentRect.bottom;
|
||||||
btn.textContent = '\u2713';
|
const tbHeight = btnContainer.offsetHeight || 28;
|
||||||
btn.title = 'Accept change';
|
// Clamp: if bottom edge would go below visible area, pin to viewport bottom
|
||||||
btn.className = 'cm-merge-accept';
|
if (targetY + tbHeight > scrollerRect.bottom) {
|
||||||
btn.onmousedown = (e) => {
|
targetY = scrollerRect.bottom - tbHeight;
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||||
return btn;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
return extensions;
|
||||||
}, [
|
}, [readOnly, showMergeControls, buildMergeExtension]);
|
||||||
original,
|
|
||||||
readOnly,
|
|
||||||
showMergeControls,
|
|
||||||
collapseUnchangedProp,
|
|
||||||
collapseMargin,
|
|
||||||
scrollToNextChunk,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
@ -477,6 +729,17 @@ export const CodeMirrorDiffView = ({
|
||||||
};
|
};
|
||||||
}, [fileName, buildExtensions, initialState]);
|
}, [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
|
// Auto-viewed detection via IntersectionObserver
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!endSentinelRef.current || !onFullyViewed) return;
|
if (!endSentinelRef.current || !onFullyViewed) return;
|
||||||
|
|
|
||||||
|
|
@ -140,12 +140,7 @@ export const ReviewToolbar = ({
|
||||||
Accept All
|
Accept All
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">Accept all changes in current file</TooltipContent>
|
||||||
<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>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -158,12 +153,7 @@ export const ReviewToolbar = ({
|
||||||
Reject All
|
Reject All
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">Reject all changes in current file</TooltipContent>
|
||||||
<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>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { get, set } from 'idb-keyval';
|
import { get, set } from 'idb-keyval';
|
||||||
|
|
||||||
const IDB_KEY = 'comment-read-state';
|
const IDB_KEY = 'comment-read-state';
|
||||||
|
const LS_KEY = 'comment-read-state';
|
||||||
const SAVE_DEBOUNCE_MS = 300;
|
const SAVE_DEBOUNCE_MS = 300;
|
||||||
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
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 cache: ReadState = {};
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
|
let idbAvailable = true; // flips to false on first IndexedDB failure
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
// --- useSyncExternalStore API ---
|
// --- useSyncExternalStore API ---
|
||||||
export function subscribe(listener: () => void): () => void {
|
export function subscribe(listener: () => void): () => void {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
if (!loaded) void loadFromIdb();
|
if (!loaded) void load();
|
||||||
return () => {
|
return () => {
|
||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
|
|
@ -46,6 +48,28 @@ export function getUnreadCount(
|
||||||
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
|
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 ---
|
// --- Internal ---
|
||||||
function hasIndexedDB(): boolean {
|
function hasIndexedDB(): boolean {
|
||||||
return typeof indexedDB !== 'undefined';
|
return typeof indexedDB !== 'undefined';
|
||||||
|
|
@ -59,54 +83,79 @@ function scheduleSave(): void {
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
saveTimer = null;
|
saveTimer = null;
|
||||||
void saveToIdb();
|
void save();
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromIdb(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
if (!hasIndexedDB()) {
|
|
||||||
loaded = true;
|
// Try IndexedDB first
|
||||||
return;
|
if (hasIndexedDB() && idbAvailable) {
|
||||||
}
|
try {
|
||||||
try {
|
const stored = await get<ReadState>(IDB_KEY);
|
||||||
const stored = await get<ReadState>(IDB_KEY);
|
if (stored && typeof stored === 'object') {
|
||||||
if (stored && typeof stored === 'object') {
|
cache = { ...stored, ...cache };
|
||||||
cache = { ...stored, ...cache }; // merge: in-memory wins over stale IDB
|
notify();
|
||||||
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;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveToIdb(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
if (!hasIndexedDB()) return;
|
if (idbAvailable && hasIndexedDB()) {
|
||||||
try {
|
try {
|
||||||
await set(IDB_KEY, cache);
|
await set(IDB_KEY, cache);
|
||||||
} catch (e) {
|
return;
|
||||||
console.error('[commentReadStorage] save failed:', e);
|
} catch {
|
||||||
|
idbAvailable = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
lsSave(cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupStale(): Promise<void> {
|
export async function cleanupStale(): Promise<void> {
|
||||||
if (!hasIndexedDB()) return;
|
const now = Date.now();
|
||||||
try {
|
const clean = (state: ReadState): { cleaned: ReadState; changed: boolean } => {
|
||||||
const stored = await get<ReadState>(IDB_KEY);
|
const result: ReadState = {};
|
||||||
if (!stored) return;
|
|
||||||
const now = Date.now();
|
|
||||||
const cleaned: ReadState = {};
|
|
||||||
let changed = false;
|
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) {
|
if (now - v < STALE_THRESHOLD_MS) {
|
||||||
cleaned[k] = v;
|
result[k] = v;
|
||||||
} else {
|
} else {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed) await set(IDB_KEY, cleaned);
|
return { cleaned: result, changed };
|
||||||
} catch (e) {
|
};
|
||||||
console.error('[commentReadStorage] cleanup failed:', e);
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,17 @@ export function initializeNotificationListeners(): () => void {
|
||||||
|
|
||||||
if (api.teams?.onTeamChange) {
|
if (api.teams?.onTeamChange) {
|
||||||
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
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).
|
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
||||||
if (!teamListRefreshTimer) {
|
if (!teamListRefreshTimer) {
|
||||||
teamListRefreshTimer = setTimeout(() => {
|
teamListRefreshTimer = setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
|
LeadActivityState,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
@ -61,6 +62,7 @@ export interface TeamSlice {
|
||||||
lastSendMessageResult: SendMessageResult | null;
|
lastSendMessageResult: SendMessageResult | null;
|
||||||
reviewActionError: string | null;
|
reviewActionError: string | null;
|
||||||
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
||||||
|
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||||
activeProvisioningRunId: string | null;
|
activeProvisioningRunId: string | null;
|
||||||
provisioningError: string | null;
|
provisioningError: string | null;
|
||||||
kanbanFilterQuery: string | null;
|
kanbanFilterQuery: string | null;
|
||||||
|
|
@ -120,6 +122,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
lastSendMessageResult: null,
|
lastSendMessageResult: null,
|
||||||
reviewActionError: null,
|
reviewActionError: null,
|
||||||
provisioningRuns: {},
|
provisioningRuns: {},
|
||||||
|
leadActivityByTeam: {},
|
||||||
activeProvisioningRunId: null,
|
activeProvisioningRunId: null,
|
||||||
provisioningError: null,
|
provisioningError: null,
|
||||||
kanbanFilterQuery: null,
|
kanbanFilterQuery: null,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export function agentAvatarUrl(name: string, size = 64): string {
|
||||||
return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
|
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(
|
export function getMemberDotClass(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean
|
isTeamProvisioning?: boolean,
|
||||||
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
|
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
|
||||||
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
|
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
|
||||||
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
|
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.status === 'unknown') return STATUS_DOT_COLORS.unknown;
|
||||||
if (member.currentTaskId) return STATUS_DOT_COLORS.active;
|
if (member.currentTaskId) return STATUS_DOT_COLORS.active;
|
||||||
return member.status === 'active' ? STATUS_DOT_COLORS.active : STATUS_DOT_COLORS.idle;
|
return member.status === 'active' ? STATUS_DOT_COLORS.active : STATUS_DOT_COLORS.idle;
|
||||||
|
|
@ -27,11 +40,15 @@ export function getMemberDotClass(
|
||||||
export function getPresenceLabel(
|
export function getPresenceLabel(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean
|
isTeamProvisioning?: boolean,
|
||||||
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
if (member.status === 'terminated') return 'terminated';
|
if (member.status === 'terminated') return 'terminated';
|
||||||
if (isTeamProvisioning) return 'connecting';
|
if (isTeamProvisioning) return 'connecting';
|
||||||
if (isTeamAlive === false) return 'offline';
|
if (isTeamAlive === false) return 'offline';
|
||||||
|
if (leadActivity && member.agentType === 'team-lead') {
|
||||||
|
return leadActivity === 'active' ? 'processing' : 'ready';
|
||||||
|
}
|
||||||
if (member.status === 'unknown') return 'idle';
|
if (member.status === 'unknown') return 'idle';
|
||||||
return member.currentTaskId ? 'working' : 'idle';
|
return member.currentTaskId ? 'working' : 'idle';
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +67,43 @@ export const TASK_STATUS_LABELS: Record<TeamTaskStatus, string> = {
|
||||||
deleted: 'Deleted',
|
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<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<
|
export const KANBAN_COLUMN_DISPLAY: Record<
|
||||||
'review' | 'approved',
|
'review' | 'approved',
|
||||||
{ label: string; bg: string; text: string }
|
{ label: string; bg: string; text: string }
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,8 @@
|
||||||
* Used during team creation and for preview in the UI.
|
* Used during team creation and for preview in the UI.
|
||||||
* Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length].
|
* 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 {
|
export function getMemberColor(index: number): string {
|
||||||
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
|
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];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import type {
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
|
LeadActivityState,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -425,6 +426,7 @@ export interface TeamsAPI {
|
||||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||||
|
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||||
onProvisioningProgress: (
|
onProvisioningProgress: (
|
||||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||||
|
|
|
||||||
|
|
@ -204,8 +204,10 @@ export interface CreateTaskRequest {
|
||||||
startImmediately?: boolean;
|
startImmediately?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||||
|
|
||||||
export interface TeamChangeEvent {
|
export interface TeamChangeEvent {
|
||||||
type: 'config' | 'inbox' | 'task';
|
type: 'config' | 'inbox' | 'task' | 'lead-activity';
|
||||||
teamName: string;
|
teamName: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
|
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
|
||||||
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
|
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
|
||||||
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
|
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
|
||||||
|
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -72,6 +73,7 @@ import {
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_UPDATE_MEMBER_ROLE,
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
} from '../../../src/preload/constants/ipcChannels';
|
} from '../../../src/preload/constants/ipcChannels';
|
||||||
|
|
@ -134,6 +136,7 @@ describe('ipc teams handlers', () => {
|
||||||
relayLeadInboxMessages: vi.fn(async () => 0),
|
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||||
getLiveLeadProcessMessages: vi.fn(() => []),
|
getLiveLeadProcessMessages: vi.fn(() => []),
|
||||||
getAliveTeams: vi.fn(() => ['my-team']),
|
getAliveTeams: vi.fn(() => ['my-team']),
|
||||||
|
getLeadActivityState: vi.fn(() => 'idle'),
|
||||||
stopTeam: vi.fn(() => undefined),
|
stopTeam: vi.fn(() => undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -174,6 +177,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
||||||
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
||||||
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).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 () => {
|
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_UPDATE_MEMBER_ROLE)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue