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_PROJECT_BRANCH,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LIST,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
TEAM_PROCESS_ALIVE,
|
||||
|
|
@ -193,6 +194,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +231,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||
}
|
||||
|
||||
function getTeamDataService(): TeamDataService {
|
||||
|
|
@ -1263,6 +1266,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
|
|||
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
|
||||
}
|
||||
|
||||
async function handleLeadActivity(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<string>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('leadActivity', async () =>
|
||||
getTeamProvisioningService().getLeadActivityState(validated.value!)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
|
|||
|
|
@ -133,8 +133,12 @@ interface ProvisioningRun {
|
|||
provisioningOutputParts: string[];
|
||||
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
||||
detectedSessionId: string | null;
|
||||
/** Lead process activity: 'active' during turn processing, 'idle' waiting for input, 'offline' after exit. */
|
||||
leadActivityState: LeadActivityState;
|
||||
}
|
||||
|
||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
type ProvisioningAuthSource =
|
||||
| 'anthropic_api_key'
|
||||
| 'anthropic_auth_token'
|
||||
|
|
@ -609,6 +613,24 @@ export class TeamProvisioningService {
|
|||
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
|
||||
}
|
||||
|
||||
getLeadActivityState(teamName: string): 'active' | 'idle' | 'offline' {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) return 'offline';
|
||||
const run = this.runs.get(runId);
|
||||
if (!run || run.processKilled || run.cancelRequested) return 'offline';
|
||||
return run.leadActivityState;
|
||||
}
|
||||
|
||||
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
|
||||
if (run.leadActivityState === state) return;
|
||||
run.leadActivityState = state;
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'lead-activity',
|
||||
teamName: run.teamName,
|
||||
detail: state,
|
||||
});
|
||||
}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
try {
|
||||
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||
|
|
@ -768,6 +790,7 @@ export class TeamProvisioningService {
|
|||
directReplyParts: [],
|
||||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1040,6 +1063,7 @@ export class TeamProvisioningService {
|
|||
directReplyParts: [],
|
||||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1283,6 +1307,7 @@ export class TeamProvisioningService {
|
|||
},
|
||||
});
|
||||
run.child.stdin.write(payload + '\n');
|
||||
this.setLeadActivity(run, 'active');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1740,6 +1765,9 @@ export class TeamProvisioningService {
|
|||
})();
|
||||
if (subtype === 'success') {
|
||||
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
||||
if (run.provisioningComplete) {
|
||||
this.setLeadActivity(run, 'idle');
|
||||
}
|
||||
if (run.leadRelayCapture) {
|
||||
const capture = run.leadRelayCapture;
|
||||
const combined = capture.textParts.join('').trim();
|
||||
|
|
@ -1800,6 +1828,9 @@ export class TeamProvisioningService {
|
|||
run.child?.stdin?.end();
|
||||
run.child?.kill();
|
||||
this.cleanupRun(run);
|
||||
} else if (run.provisioningComplete) {
|
||||
// Post-provisioning error: process alive, waiting for input
|
||||
this.setLeadActivity(run, 'idle');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1813,6 +1844,7 @@ export class TeamProvisioningService {
|
|||
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||
if (run.cancelRequested) return;
|
||||
run.provisioningComplete = true;
|
||||
this.setLeadActivity(run, 'idle');
|
||||
|
||||
// Clear provisioning timeout — no longer needed
|
||||
if (run.timeoutHandle) {
|
||||
|
|
@ -1880,6 +1912,7 @@ export class TeamProvisioningService {
|
|||
* Remove a run from tracking maps.
|
||||
*/
|
||||
private cleanupRun(run: ProvisioningRun): void {
|
||||
this.setLeadActivity(run, 'offline');
|
||||
if (run.timeoutHandle) {
|
||||
clearTimeout(run.timeoutHandle);
|
||||
run.timeoutHandle = null;
|
||||
|
|
|
|||
|
|
@ -285,6 +285,9 @@ export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole';
|
|||
/** Get attachment data for a message */
|
||||
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
||||
|
||||
/** Get lead process activity state (active/idle/offline) */
|
||||
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
||||
|
||||
// =============================================================================
|
||||
// Review API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LIST,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
TEAM_PROCESS_ALIVE,
|
||||
|
|
@ -663,6 +664,10 @@ const electronAPI: ElectronAPI = {
|
|||
getAttachments: async (teamName: string, messageId: string) => {
|
||||
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
|
||||
},
|
||||
getLeadActivity: async (teamName: string) => {
|
||||
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
||||
return result as 'active' | 'idle' | 'offline';
|
||||
},
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||
ipcRenderer.on(
|
||||
TEAM_CHANGE,
|
||||
|
|
@ -761,9 +766,9 @@ const electronAPI: ElectronAPI = {
|
|||
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
|
||||
},
|
||||
onCmdN: (callback: () => void): (() => void) => {
|
||||
const handler = () => callback();
|
||||
const handler = (): void => callback();
|
||||
ipcRenderer.on('review:cmdN', handler);
|
||||
return () => {
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener('review:cmdN', handler);
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -761,6 +761,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<AttachmentFileData[]> => {
|
||||
return [];
|
||||
},
|
||||
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||
return 'offline';
|
||||
},
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||
return this.addEventListener('team-change', (data: unknown) =>
|
||||
callback(null, data as TeamChangeEvent)
|
||||
|
|
@ -775,44 +778,44 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
|
||||
// Review API stubs
|
||||
review = {
|
||||
getAgentChanges: async (_teamName: string, _memberName: string) => {
|
||||
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
getTaskChanges: async (_teamName: string, _taskId: string) => {
|
||||
getTaskChanges: async (_teamName: string, _taskId: string): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
getChangeStats: async (_teamName: string, _memberName: string) => {
|
||||
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
getFileContent: async (
|
||||
_teamName: string,
|
||||
_memberName: string | undefined,
|
||||
_filePath: string
|
||||
) => {
|
||||
): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
applyDecisions: async () => {
|
||||
applyDecisions: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
// Phase 2 stubs
|
||||
checkConflict: async () => {
|
||||
checkConflict: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
rejectHunks: async () => {
|
||||
rejectHunks: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
rejectFile: async () => {
|
||||
rejectFile: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
previewReject: async () => {
|
||||
previewReject: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
// Editable diff stubs
|
||||
saveEditedFile: async () => {
|
||||
saveEditedFile: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
// Phase 4 stubs
|
||||
getGitFileLog: async () => {
|
||||
getGitFileLog: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
launchTeam,
|
||||
provisioningError,
|
||||
isTeamProvisioning,
|
||||
leadActivityByTeam,
|
||||
refreshTeamData,
|
||||
kanbanFilterQuery,
|
||||
clearKanbanFilter,
|
||||
|
|
@ -201,6 +202,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||
),
|
||||
leadActivityByTeam: s.leadActivityByTeam,
|
||||
refreshTeamData: s.refreshTeamData,
|
||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||
clearKanbanFilter: s.clearKanbanFilter,
|
||||
|
|
@ -792,6 +794,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={leadActivityByTeam[teamName]}
|
||||
onMemberClick={setSelectedMember}
|
||||
onSendMessage={(member) => {
|
||||
setSendDialogRecipient(member.name);
|
||||
|
|
@ -1125,6 +1128,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
messages={data.messages}
|
||||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={leadActivityByTeam[teamName]}
|
||||
onClose={() => setSelectedMember(null)}
|
||||
onSendMessage={() => {
|
||||
const name = selectedMember?.name ?? '';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
} from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||
import {
|
||||
|
|
@ -31,7 +33,12 @@ import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
|||
import { TeamEmptyState } from './TeamEmptyState';
|
||||
|
||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||
import type { TeamCreateRequest, TeamProvisioningProgress, TeamSummary } from '@shared/types';
|
||||
import type {
|
||||
TeamCreateRequest,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
TeamSummaryMember,
|
||||
} from '@shared/types';
|
||||
|
||||
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
||||
const base = sourceName.replace(/-\d+$/, '');
|
||||
|
|
@ -44,7 +51,7 @@ function generateUniqueName(sourceName: string, existingNames: string[]): string
|
|||
}
|
||||
}
|
||||
|
||||
type TeamStatus = 'running' | 'provisioning' | 'offline';
|
||||
type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline';
|
||||
|
||||
function getRecentProjects(team: TeamSummary): string[] {
|
||||
const history = team.projectPathHistory;
|
||||
|
|
@ -58,13 +65,69 @@ function folderName(fullPath: string): string {
|
|||
return getBaseName(fullPath) || fullPath;
|
||||
}
|
||||
|
||||
function renderMemberChips(members: TeamSummaryMember[]): React.JSX.Element {
|
||||
const teamColorMap = buildMemberColorMap(members);
|
||||
return (
|
||||
<>
|
||||
{members.map((m) => {
|
||||
const resolvedColor = teamColorMap.get(m.name);
|
||||
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
return (
|
||||
<span key={m.name} className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={
|
||||
memberColor
|
||||
? {
|
||||
backgroundColor: memberColor.badge,
|
||||
color: memberColor.text,
|
||||
border: `1px solid ${memberColor.border}40`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
{m.role ? (
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">{m.role}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX.Element | null {
|
||||
const recentPaths = getRecentProjects(team);
|
||||
if (recentPaths.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
<FolderOpen size={10} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{recentPaths.map((p, i) => (
|
||||
<span key={p} title={p}>
|
||||
{i === 0 && (status === 'active' || status === 'idle') ? (
|
||||
<span className="text-emerald-400">{folderName(p)}</span>
|
||||
) : (
|
||||
folderName(p)
|
||||
)}
|
||||
{i < recentPaths.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTeamStatus(
|
||||
teamName: string,
|
||||
aliveTeams: string[],
|
||||
provisioningRuns: Record<string, TeamProvisioningProgress>
|
||||
provisioningRuns: Record<string, TeamProvisioningProgress>,
|
||||
leadActivityByTeam: Record<string, string>
|
||||
): TeamStatus {
|
||||
if (aliveTeams.includes(teamName)) {
|
||||
return 'running';
|
||||
return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle';
|
||||
}
|
||||
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
||||
for (const run of Object.values(provisioningRuns)) {
|
||||
|
|
@ -77,10 +140,17 @@ function resolveTeamStatus(
|
|||
|
||||
const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'active':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
case 'idle':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<span className="size-1.5 rounded-full bg-emerald-400" />
|
||||
Running
|
||||
</span>
|
||||
);
|
||||
|
|
@ -141,14 +211,16 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
activeProjectId: s.activeProjectId,
|
||||
}))
|
||||
);
|
||||
const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
|
||||
useShallow((s) => ({
|
||||
connectionMode: s.connectionMode,
|
||||
createTeam: s.createTeam,
|
||||
provisioningError: s.provisioningError,
|
||||
provisioningRuns: s.provisioningRuns,
|
||||
}))
|
||||
);
|
||||
const { connectionMode, createTeam, provisioningError, provisioningRuns, leadActivityByTeam } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
connectionMode: s.connectionMode,
|
||||
createTeam: s.createTeam,
|
||||
provisioningError: s.provisioningError,
|
||||
provisioningRuns: s.provisioningRuns,
|
||||
leadActivityByTeam: s.leadActivityByTeam,
|
||||
}))
|
||||
);
|
||||
const canCreate = electronMode && connectionMode === 'local';
|
||||
|
||||
// Fetch alive teams on mount and when teams list changes
|
||||
|
|
@ -275,11 +347,18 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const handleDeleteTeam = useCallback(
|
||||
(teamName: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirmed = window.confirm(`Delete team "${teamName}"? This action is irreversible.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
void deleteTeam(teamName);
|
||||
void (async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete team',
|
||||
message: `Delete team "${teamName}"? This action is irreversible.`,
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (confirmed) {
|
||||
void deleteTeam(teamName);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[deleteTeam]
|
||||
);
|
||||
|
|
@ -441,22 +520,17 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
|
||||
if (teamsLoading) {
|
||||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
{renderHeader()}
|
||||
const renderContent = (): React.JSX.Element => {
|
||||
if (teamsLoading) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
|
||||
Loading teams...
|
||||
</div>
|
||||
{createDialogElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (teamsError) {
|
||||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
{renderHeader()}
|
||||
if (teamsError) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-red-400">Failed to load teams</p>
|
||||
|
|
@ -473,259 +547,212 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{createDialogElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (teams.length === 0) {
|
||||
return <TeamEmptyState />;
|
||||
}
|
||||
|
||||
if (filteredTeams.length === 0 && searchQuery.trim()) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
|
||||
No teams matching "{searchQuery.trim()}"
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (teams.length === 0) {
|
||||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
{renderHeader()}
|
||||
<TeamEmptyState />
|
||||
{createDialogElement}
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredTeams.map((team) => {
|
||||
const status = resolveTeamStatus(
|
||||
team.teamName,
|
||||
aliveTeams,
|
||||
provisioningRuns,
|
||||
leadActivityByTeam
|
||||
);
|
||||
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
|
||||
const matchesCurrentProject =
|
||||
!!currentProjectPath &&
|
||||
((team.projectPath ? normalizePath(team.projectPath) === currentProjectPath : false) ||
|
||||
(team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
|
||||
false));
|
||||
return (
|
||||
<div
|
||||
key={team.teamName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
|
||||
matchesCurrentProject
|
||||
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
|
||||
: 'border-[var(--color-border)]'
|
||||
}`}
|
||||
style={
|
||||
teamColorSet
|
||||
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => openTeamTab(team.teamName, team.projectPath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openTeamTab(team.teamName, team.projectPath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{teamColorSet ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
|
||||
style={{ backgroundColor: teamColorSet.badge }}
|
||||
/>
|
||||
) : null}
|
||||
<div className={teamColorSet ? 'relative z-10' : undefined}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
|
||||
{team.displayName}
|
||||
</h3>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{(status === 'active' || status === 'idle') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
|
||||
onClick={(e) => handleStopTeam(team.teamName, e)}
|
||||
disabled={stoppingTeamName === team.teamName}
|
||||
aria-label="Stop team"
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
|
||||
onClick={(e) => handleCopyTeam(team.teamName, e)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Copy team</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
|
||||
onClick={(e) => handleDeleteTeam(team.teamName, e)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Delete team</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex min-h-10 items-start gap-2">
|
||||
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
|
||||
{team.description || 'No description'}
|
||||
</p>
|
||||
{team.projectPath &&
|
||||
(() => {
|
||||
const branch = branchByPath.get(normalizePath(team.projectPath));
|
||||
if (!branch) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
||||
title={branch}
|
||||
>
|
||||
<GitBranch size={10} />
|
||||
<span className="max-w-24 truncate">{branch}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{team.members && team.members.length > 0 ? (
|
||||
renderMemberChips(team.members)
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Members: {team.memberCount}
|
||||
</Badge>
|
||||
)}
|
||||
{(() => {
|
||||
const tc = taskCountsByTeam.get(team.teamName);
|
||||
const pending = tc?.pending ?? 0;
|
||||
const inProgress = tc?.inProgress ?? 0;
|
||||
const completed = tc?.completed ?? 0;
|
||||
const totalTasks = pending + inProgress + completed;
|
||||
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
|
||||
return (
|
||||
<div className="mt-2 w-full space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
||||
role="progressbar"
|
||||
aria-valuenow={completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={totalTasks}
|
||||
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
||||
style={{ width: `${Math.round(completedRatio * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
||||
{completed}/{totalTasks}
|
||||
</span>
|
||||
</div>
|
||||
{totalTasks > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{inProgress > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Play size={10} className="shrink-0 text-blue-400" />
|
||||
{inProgress} in_progress
|
||||
</span>
|
||||
)}
|
||||
{pending > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock size={10} className="shrink-0 text-amber-400" />
|
||||
{pending} pending
|
||||
</span>
|
||||
)}
|
||||
{completed > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
|
||||
{completed} completed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{renderTeamRecentPaths(team, status)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="size-full overflow-auto p-4">
|
||||
{renderHeader()}
|
||||
|
||||
{filteredTeams.length === 0 && searchQuery.trim() ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
|
||||
No teams matching "{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>
|
||||
)}
|
||||
{renderContent()}
|
||||
{createDialogElement}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -18,6 +19,7 @@ export const ActiveTasksBlock = ({
|
|||
onMemberClick,
|
||||
onTaskClick,
|
||||
}: ActiveTasksBlockProps): React.JSX.Element | null => {
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
||||
const working = members.filter((m) => m.currentTaskId != null);
|
||||
if (working.length === 0) return null;
|
||||
|
|
@ -30,7 +32,7 @@ export const ActiveTasksBlock = ({
|
|||
{working.map((member) => {
|
||||
const taskId = member.currentTaskId!;
|
||||
const task = taskMap.get(taskId);
|
||||
const colors = getTeamColorSet(member.color ?? '');
|
||||
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
|
||||
const roleLabel = formatAgentRole(
|
||||
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import { ActivityItem } from './ActivityItem';
|
||||
|
||||
|
|
@ -104,35 +104,27 @@ export const ActivityTimeline = ({
|
|||
onMemberClick,
|
||||
onMessageVisible,
|
||||
}: ActivityTimelineProps): React.JSX.Element => {
|
||||
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||
if (members) {
|
||||
for (const m of members) {
|
||||
const info = {
|
||||
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
|
||||
color: m.color,
|
||||
color: colorMap.get(m.name),
|
||||
};
|
||||
memberInfo.set(m.name, info);
|
||||
if (m.agentType && m.agentType !== m.name) {
|
||||
memberInfo.set(m.agentType, info);
|
||||
}
|
||||
}
|
||||
// Map "user" to team-lead's resolved color and role
|
||||
const leadMember = members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
|
||||
);
|
||||
if (leadMember) {
|
||||
const leadInfo = memberInfo.get(leadMember.name);
|
||||
if (leadInfo) {
|
||||
const teamLeadColor = leadInfo.color ?? getMemberColorByName('team-lead');
|
||||
const resolvedLeadInfo = { role: leadInfo.role, color: teamLeadColor };
|
||||
memberInfo.set('team-lead', resolvedLeadInfo);
|
||||
memberInfo.set(leadMember.name, resolvedLeadInfo);
|
||||
if (
|
||||
leadMember.agentType &&
|
||||
leadMember.agentType !== 'team-lead' &&
|
||||
leadMember.agentType !== leadMember.name
|
||||
) {
|
||||
memberInfo.set(leadMember.agentType, resolvedLeadInfo);
|
||||
}
|
||||
memberInfo.set('user', { role: leadInfo.role, color: colorMap.get('user') });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +149,7 @@ export const ActivityTimeline = ({
|
|||
const info = memberInfo.get(message.from);
|
||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||
const recipientColor =
|
||||
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
||||
const isUnread = readState
|
||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export const PendingRepliesBlock = ({
|
|||
pendingRepliesByMember,
|
||||
onMemberClick,
|
||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const pending = Object.entries(pendingRepliesByMember)
|
||||
.map(([name, sentAtMs]) => ({
|
||||
member: members.find((m) => m.name === name) ?? null,
|
||||
|
|
@ -34,7 +36,7 @@ export const PendingRepliesBlock = ({
|
|||
Awaiting replies
|
||||
</p>
|
||||
{pending.map(({ member, sentAtMs }) => {
|
||||
const colors = getTeamColorSet(member.color ?? '');
|
||||
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
|
||||
const roleLabel = formatAgentRole(
|
||||
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { AlertTriangle, Search } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -66,6 +67,7 @@ export const CreateTaskDialog = ({
|
|||
onSubmit,
|
||||
submitting = false,
|
||||
}: CreateTaskDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const [subject, setSubject] = useState(defaultSubject);
|
||||
const descriptionDraft = useDraftPersistence({
|
||||
key: `createTask:${teamName}:description`,
|
||||
|
|
@ -103,9 +105,9 @@ export const CreateTaskDialog = ({
|
|||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
color: colorMap.get(m.name),
|
||||
})),
|
||||
[members]
|
||||
[members, colorMap]
|
||||
);
|
||||
|
||||
const requiresOwner = defaultStartImmediately === true;
|
||||
|
|
@ -161,7 +163,8 @@ export const CreateTaskDialog = ({
|
|||
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const memberColor = m.color ? getTeamColorSet(m.color) : null;
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -248,15 +249,16 @@ export const LaunchTeamDialog = ({
|
|||
);
|
||||
}, [activeTeams, effectiveCwd, teamName]);
|
||||
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
color: colorMap.get(m.name),
|
||||
})),
|
||||
[members]
|
||||
[members, colorMap]
|
||||
);
|
||||
|
||||
const activeError = localError ?? provisioningError;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Label } from '@renderer/components/ui/label';
|
|||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
|
@ -38,6 +39,7 @@ export const ReviewDialog = ({
|
|||
key: `requestChanges:${teamName}:${taskId ?? ''}`,
|
||||
enabled: Boolean(teamName && taskId),
|
||||
});
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
|
|
@ -45,9 +47,9 @@ export const ReviewDialog = ({
|
|||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
color: colorMap.get(m.name),
|
||||
})),
|
||||
[members]
|
||||
[members, colorMap]
|
||||
);
|
||||
|
||||
const handleCancel = (): void => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -59,6 +60,7 @@ export const SendMessageDialog = ({
|
|||
onSend,
|
||||
onClose,
|
||||
}: SendMessageDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||
const [member, setMember] = useState('');
|
||||
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
||||
|
|
@ -102,9 +104,9 @@ export const SendMessageDialog = ({
|
|||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
color: colorMap.get(m.name),
|
||||
})),
|
||||
[members]
|
||||
[members, colorMap]
|
||||
);
|
||||
|
||||
const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
|
||||
|
|
@ -145,7 +147,8 @@ export const SendMessageDialog = ({
|
|||
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const memberColor = m.color ? getTeamColorSet(m.color) : null;
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
|
||||
|
|
@ -52,6 +54,7 @@ export const TaskCommentsSection = ({
|
|||
}, []);
|
||||
|
||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
|
|
@ -59,9 +62,9 @@ export const TaskCommentsSection = ({
|
|||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
color: colorMap.get(m.name),
|
||||
})),
|
||||
[members]
|
||||
[members, colorMap]
|
||||
);
|
||||
|
||||
const trimmed = draft.value.trim();
|
||||
|
|
@ -102,11 +105,10 @@ export const TaskCommentsSection = ({
|
|||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
color:
|
||||
comment.author === 'user'
|
||||
? 'var(--color-text-secondary)'
|
||||
: (members.find((m) => m.name === comment.author)?.color ??
|
||||
'var(--color-text-secondary)'),
|
||||
color: (() => {
|
||||
const rc = colorMap.get(comment.author);
|
||||
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
|
||||
})(),
|
||||
}}
|
||||
>
|
||||
{comment.author}
|
||||
|
|
@ -228,11 +230,10 @@ export const TaskCommentsSection = ({
|
|||
<span
|
||||
className="font-semibold"
|
||||
style={{
|
||||
color:
|
||||
replyTo.author === 'user'
|
||||
? 'var(--color-text-secondary)'
|
||||
: (members.find((m) => m.name === replyTo.author)?.color ??
|
||||
'var(--color-text-secondary)'),
|
||||
color: (() => {
|
||||
const rc = colorMap.get(replyTo.author);
|
||||
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
|
||||
})(),
|
||||
}}
|
||||
>
|
||||
@{replyTo.author}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||
|
|
@ -25,6 +25,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
buildMemberColorMap,
|
||||
KANBAN_COLUMN_DISPLAY,
|
||||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
|
|
@ -59,6 +60,7 @@ export const TaskDetailDialog = ({
|
|||
onScrollToTask,
|
||||
onOwnerChange,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -110,7 +112,6 @@ export const TaskDetailDialog = ({
|
|||
t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id)
|
||||
)
|
||||
.map((t) => t.id);
|
||||
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
|
||||
const isTodo = status === 'pending' && !kanbanColumn;
|
||||
const canReassign = isTodo && onOwnerChange;
|
||||
|
||||
|
|
@ -151,7 +152,8 @@ export const TaskDetailDialog = ({
|
|||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const memberColor = m.color ? getTeamColorSet(m.color) : null;
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
|
@ -174,7 +176,11 @@ export const TaskDetailDialog = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
) : currentTask.owner ? (
|
||||
<MemberBadge name={currentTask.owner} color={ownerMember?.color} size="md" />
|
||||
<MemberBadge
|
||||
name={currentTask.owner}
|
||||
color={colorMap.get(currentTask.owner)}
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||
|
|
@ -7,6 +7,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
ArrowLeftFromLine,
|
||||
ArrowRightFromLine,
|
||||
|
|
@ -143,6 +144,7 @@ export const KanbanTaskCard = ({
|
|||
onTaskClick,
|
||||
onViewChanges,
|
||||
}: KanbanTaskCardProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
|
|
@ -184,12 +186,7 @@ export const KanbanTaskCard = ({
|
|||
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
</Badge>
|
||||
{task.owner ? (
|
||||
<MemberBadge
|
||||
name={task.owner}
|
||||
color={members.find((m) => m.name === task.owner)?.color}
|
||||
/>
|
||||
) : null}
|
||||
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
||||
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</h5>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/u
|
|||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface MemberCardProps {
|
||||
member: ResolvedTeamMember;
|
||||
|
|
@ -14,6 +14,7 @@ interface MemberCardProps {
|
|||
taskCounts?: TaskStatusCounts | null;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
currentTask?: TeamTaskWithKanban | null;
|
||||
isAwaitingReply?: boolean;
|
||||
isRemoved?: boolean;
|
||||
|
|
@ -29,6 +30,7 @@ export const MemberCard = ({
|
|||
taskCounts,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
currentTask,
|
||||
isAwaitingReply,
|
||||
isRemoved,
|
||||
|
|
@ -37,8 +39,8 @@ export const MemberCard = ({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
const pending = taskCounts?.pending ?? 0;
|
||||
const inProgress = taskCounts?.inProgress ?? 0;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ import { MemberMessagesTab } from './MemberMessagesTab';
|
|||
import { MemberStatsTab } from './MemberStatsTab';
|
||||
import { MemberTasksTab } from './MemberTasksTab';
|
||||
|
||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type {
|
||||
InboxMessage,
|
||||
LeadActivityState,
|
||||
ResolvedTeamMember,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
interface MemberDetailDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -22,6 +27,7 @@ interface MemberDetailDialogProps {
|
|||
messages: InboxMessage[];
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
onClose: () => void;
|
||||
onSendMessage: () => void;
|
||||
onAssignTask: () => void;
|
||||
|
|
@ -39,6 +45,7 @@ export const MemberDetailDialog = ({
|
|||
messages,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
onClose,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
|
|
@ -80,6 +87,7 @@ export const MemberDetailDialog = ({
|
|||
member={member}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
||||
onUpdateRole={
|
||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import { Pencil } from 'lucide-react';
|
|||
|
||||
import { MemberRoleEditor } from './MemberRoleEditor';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
import type { LeadActivityState, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
interface MemberDetailHeaderProps {
|
||||
member: ResolvedTeamMember;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||
updatingRole?: boolean;
|
||||
}
|
||||
|
|
@ -22,14 +23,15 @@ export const MemberDetailHeader = ({
|
|||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
onUpdateRole,
|
||||
updatingRole,
|
||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const role = member.role || formatAgentRole(member.agentType);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
|
||||
const canEditRole =
|
||||
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import { MemberCard } from './MemberCard';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface MemberListProps {
|
||||
members: ResolvedTeamMember[];
|
||||
|
|
@ -12,6 +12,7 @@ interface MemberListProps {
|
|||
pendingRepliesByMember?: Record<string, number>;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||
|
|
@ -25,6 +26,7 @@ export const MemberList = ({
|
|||
pendingRepliesByMember,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
onMemberClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
|
|
@ -32,6 +34,7 @@ export const MemberList = ({
|
|||
}: MemberListProps): React.JSX.Element => {
|
||||
const activeMembers = members.filter((m) => !m.removedAt);
|
||||
const removedMembers = members.filter((m) => m.removedAt);
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
|
|
@ -41,11 +44,7 @@ export const MemberList = ({
|
|||
);
|
||||
}
|
||||
|
||||
const renderCard = (
|
||||
member: ResolvedTeamMember,
|
||||
index: number,
|
||||
isRemoved: boolean
|
||||
): React.JSX.Element => {
|
||||
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
||||
const currentTask =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||
|
|
@ -53,10 +52,11 @@ export const MemberList = ({
|
|||
<MemberCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
memberColor={member.color ?? getMemberColor(index)}
|
||||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
||||
currentTask={isRemoved ? null : currentTask}
|
||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||
isRemoved={isRemoved}
|
||||
|
|
@ -70,15 +70,13 @@ export const MemberList = ({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{activeMembers.map((member, index) => renderCard(member, index, false))}
|
||||
{activeMembers.map((member) => renderCard(member, false))}
|
||||
{removedMembers.length > 0 && (
|
||||
<>
|
||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
Removed ({removedMembers.length})
|
||||
</div>
|
||||
{removedMembers.map((member, index) =>
|
||||
renderCard(member, activeMembers.length + index, true)
|
||||
)}
|
||||
{removedMembers.map((member) => renderCard(member, true))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Send } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -61,15 +62,17 @@ export const MessageComposer = ({
|
|||
handleDrop,
|
||||
} = useAttachments();
|
||||
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
color: colorMap.get(m.name),
|
||||
})),
|
||||
[members]
|
||||
[members, colorMap]
|
||||
);
|
||||
|
||||
const trimmed = draft.value.trim();
|
||||
|
|
@ -77,7 +80,8 @@ export const MessageComposer = ({
|
|||
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
|
||||
|
||||
const selectedMember = members.find((m) => m.name === recipient);
|
||||
const selectedColorSet = selectedMember?.color ? getTeamColorSet(selectedMember.color) : null;
|
||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||
const selectedColorSet = selectedResolvedColor ? getTeamColorSet(selectedResolvedColor) : null;
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
||||
|
||||
|
|
@ -201,7 +205,8 @@ export const MessageComposer = ({
|
|||
<PopoverContent align="start" className="w-56 p-1.5">
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
{members.map((m) => {
|
||||
const colorSet = m.color ? getTeamColorSet(m.color) : null;
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const isSelected = m.name === recipient;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export const ChangeReviewDialog = ({
|
|||
setHunkDecision,
|
||||
setCollapseUnchanged,
|
||||
fetchFileContent,
|
||||
acceptAll,
|
||||
rejectAll,
|
||||
acceptAllFile,
|
||||
rejectAllFile,
|
||||
applyReview,
|
||||
// Editable diff
|
||||
editedContents,
|
||||
|
|
@ -113,15 +113,6 @@ export const ChangeReviewDialog = ({
|
|||
progress: viewedProgress,
|
||||
} = useViewedFiles(teamName, scopeKey, allFilePaths);
|
||||
|
||||
// When collapseUnchanged changes, invalidate cached state for current file
|
||||
// so the editor is recreated with the new extension config
|
||||
useEffect(() => {
|
||||
if (selectedReviewFilePath) {
|
||||
editorStateCache.current.delete(selectedReviewFilePath);
|
||||
}
|
||||
queueMicrotask(() => setCachedInitialState(undefined));
|
||||
}, [collapseUnchanged]); // eslint-disable-line react-hooks/exhaustive-deps -- only collapseUnchanged triggers cache invalidation
|
||||
|
||||
// Editable diff computed values
|
||||
const editedCount = Object.keys(editedContents).length;
|
||||
const hasCurrentFileEdits = !!(
|
||||
|
|
@ -144,14 +135,14 @@ export const ChangeReviewDialog = ({
|
|||
const handleAcceptAll = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) acceptAllChunks(view);
|
||||
acceptAll();
|
||||
}, [acceptAll]);
|
||||
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, acceptAllFile]);
|
||||
|
||||
const handleRejectAll = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) rejectAllChunks(view);
|
||||
rejectAll();
|
||||
}, [rejectAll]);
|
||||
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, rejectAllFile]);
|
||||
|
||||
const handleSaveCurrentFile = useCallback(() => {
|
||||
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { languages } from '@codemirror/language-data';
|
|||
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
||||
|
||||
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
|
||||
|
||||
|
|
@ -147,7 +147,14 @@ const diffTheme = EditorView.theme({
|
|||
backgroundColor: 'var(--color-surface)',
|
||||
borderRight: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '12px',
|
||||
fontSize: '11px',
|
||||
minWidth: 'auto',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 4px 0 8px',
|
||||
minWidth: '2ch',
|
||||
textAlign: 'right',
|
||||
opacity: '0.5',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'transparent',
|
||||
|
|
@ -167,51 +174,79 @@ const diffTheme = EditorView.theme({
|
|||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||
},
|
||||
// Diff-specific styles — line-level backgrounds (no per-character underlines)
|
||||
'.cm-changedLine': {
|
||||
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
|
||||
// Diff-specific line/block backgrounds
|
||||
'.cm-changedLine': { backgroundColor: '#1a3a1a !important' },
|
||||
'.cm-deletedChunk': { backgroundColor: '#241517', position: 'relative', overflow: 'visible' },
|
||||
'.cm-insertedLine': { backgroundColor: '#1a3a1a !important' },
|
||||
'.cm-deletedLine': { backgroundColor: '#241517 !important' },
|
||||
// Merge toolbar — absolute, Y set dynamically by mousemove handler
|
||||
'.cm-deletedChunk .cm-chunkButtons': {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
insetInlineEnd: '8px',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
'.cm-deletedChunk': {
|
||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
||||
'.cm-merge-toolbar': {
|
||||
display: 'none',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
'&.cm-merge-toolbar-active': {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
'.cm-insertedLine': {
|
||||
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
|
||||
'.cm-merge-nav': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0',
|
||||
marginRight: '2px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.cm-deletedLine': {
|
||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
||||
},
|
||||
// Merge control buttons
|
||||
'.cm-merge-accept': {
|
||||
'.cm-merge-nav-btn': {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
margin: '0 2px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '13px',
|
||||
lineHeight: '20px',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||||
},
|
||||
'.cm-merge-nav-counter': {
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
padding: '0 2px',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
'.cm-merge-undo': {
|
||||
cursor: 'pointer',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '5px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '18px',
|
||||
display: 'inline-block',
|
||||
lineHeight: '20px',
|
||||
color: 'var(--color-text)',
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
border: '1px solid var(--color-border)',
|
||||
'&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' },
|
||||
'& kbd': { fontSize: '10px', color: 'var(--color-text-muted)', marginLeft: '4px' },
|
||||
},
|
||||
'.cm-merge-keep': {
|
||||
cursor: 'pointer',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '5px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '20px',
|
||||
color: '#3fb950',
|
||||
backgroundColor: 'rgba(46, 160, 67, 0.15)',
|
||||
border: '1px solid rgba(46, 160, 67, 0.3)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(46, 160, 67, 0.3)',
|
||||
},
|
||||
},
|
||||
'.cm-merge-reject': {
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
margin: '0 2px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '18px',
|
||||
display: 'inline-block',
|
||||
color: '#f85149',
|
||||
backgroundColor: 'rgba(248, 81, 73, 0.15)',
|
||||
border: '1px solid rgba(248, 81, 73, 0.3)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(248, 81, 73, 0.3)',
|
||||
},
|
||||
backgroundColor: 'rgba(46, 160, 67, 0.25)',
|
||||
border: '1px solid rgba(46, 160, 67, 0.4)',
|
||||
'&:hover': { backgroundColor: 'rgba(46, 160, 67, 0.4)' },
|
||||
'& kbd': { fontSize: '10px', color: 'rgba(63, 185, 80, 0.7)', marginLeft: '4px' },
|
||||
},
|
||||
// Collapse unchanged region marker
|
||||
'.cm-collapsedLines': {
|
||||
|
|
@ -268,10 +303,152 @@ export const CodeMirrorDiffView = ({
|
|||
|
||||
// Compartment for lazy-injected language support
|
||||
const langCompartment = useRef(new Compartment());
|
||||
// Compartment for merge view — allows dynamic collapse reconfigure without editor recreation
|
||||
const mergeCompartment = useRef(new Compartment());
|
||||
|
||||
// Collapse as ref — used in buildExtensions (initial value) without triggering full rebuild
|
||||
const collapseRef = useRef({ enabled: collapseUnchangedProp, margin: collapseMargin });
|
||||
useEffect(() => {
|
||||
collapseRef.current = { enabled: collapseUnchangedProp, margin: collapseMargin };
|
||||
}, [collapseUnchangedProp, collapseMargin]);
|
||||
|
||||
/** Build unified merge view extension. Extracted for dynamic compartment reconfigure. */
|
||||
const buildMergeExtension = useCallback(
|
||||
(collapse: boolean, margin: number): Extension => {
|
||||
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
||||
original,
|
||||
highlightChanges: false,
|
||||
gutter: true,
|
||||
syntaxHighlightDeletions: true,
|
||||
};
|
||||
|
||||
if (collapse) {
|
||||
mergeConfig.collapseUnchanged = {
|
||||
margin,
|
||||
minSize: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (showMergeControls) {
|
||||
// NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge.
|
||||
// CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes.
|
||||
// When EditorView is recreated (e.g. from cached initialState), toDOM() returns
|
||||
// the OLD cached DOM whose `action` closure references the DESTROYED view.
|
||||
// Instead, we call acceptChunk/rejectChunk directly with viewRef.current.
|
||||
//
|
||||
// CM calls mergeControls twice per chunk: 'accept' first, 'reject' second.
|
||||
// Both elements go into `.cm-chunkButtons`. We return the full toolbar for
|
||||
// 'accept' and a hidden span for 'reject'.
|
||||
mergeConfig.mergeControls = (type, _action) => {
|
||||
if (type === 'reject') {
|
||||
const empty = document.createElement('span');
|
||||
empty.style.display = 'none';
|
||||
return empty;
|
||||
}
|
||||
|
||||
// --- Full toolbar for 'accept' ---
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'cm-merge-toolbar';
|
||||
|
||||
// Navigation section (hidden by default, shown if >1 chunks)
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'cm-merge-nav';
|
||||
nav.style.display = 'none';
|
||||
|
||||
const prevBtn = document.createElement('button');
|
||||
prevBtn.className = 'cm-merge-nav-btn';
|
||||
prevBtn.textContent = '\u2227';
|
||||
prevBtn.title = 'Previous chunk';
|
||||
prevBtn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
const v = viewRef.current;
|
||||
if (v) goToPreviousChunk(v);
|
||||
};
|
||||
|
||||
const counter = document.createElement('span');
|
||||
counter.className = 'cm-merge-nav-counter';
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'cm-merge-nav-btn';
|
||||
nextBtn.textContent = '\u2228';
|
||||
nextBtn.title = 'Next chunk';
|
||||
nextBtn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
const v = viewRef.current;
|
||||
if (v) goToNextChunk(v);
|
||||
};
|
||||
|
||||
nav.append(prevBtn, counter, nextBtn);
|
||||
toolbar.append(nav);
|
||||
|
||||
// Helper: create button with label + kbd shortcut
|
||||
const makeBtn = (cls: string, label: string, shortcut: string): HTMLButtonElement => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = cls;
|
||||
btn.append(document.createTextNode(label + ' '));
|
||||
const kbd = document.createElement('kbd');
|
||||
kbd.textContent = shortcut;
|
||||
btn.append(kbd);
|
||||
return btn;
|
||||
};
|
||||
|
||||
// Undo button (reject action)
|
||||
const undoBtn = makeBtn('cm-merge-undo', 'Undo', '\u2318N');
|
||||
undoBtn.title = 'Reject change (⌘N)';
|
||||
undoBtn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
const v = viewRef.current;
|
||||
if (v) {
|
||||
const pos = v.posAtDOM(toolbar);
|
||||
const idx = computeHunkIndexAtPos(v.state, pos);
|
||||
rejectChunk(v, pos);
|
||||
onRejectRef.current?.(idx);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
};
|
||||
toolbar.append(undoBtn);
|
||||
|
||||
// Keep button (accept action)
|
||||
const keepBtn = makeBtn('cm-merge-keep', 'Keep', '\u2318Y');
|
||||
keepBtn.title = 'Accept change (⌘Y)';
|
||||
keepBtn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
const v = viewRef.current;
|
||||
if (v) {
|
||||
const pos = v.posAtDOM(toolbar);
|
||||
const idx = computeHunkIndexAtPos(v.state, pos);
|
||||
acceptChunk(v, pos);
|
||||
onAcceptRef.current?.(idx);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
};
|
||||
toolbar.append(keepBtn);
|
||||
|
||||
// Deferred: compute chunk index + show nav if >1 chunks
|
||||
requestAnimationFrame(() => {
|
||||
const v = viewRef.current;
|
||||
if (!v) return;
|
||||
const chunks = getChunks(v.state);
|
||||
if (!chunks || chunks.chunks.length <= 1) return;
|
||||
const pos = v.posAtDOM(toolbar);
|
||||
const idx = computeHunkIndexAtPos(v.state, pos);
|
||||
counter.textContent = `${idx + 1} of ${chunks.chunks.length}`;
|
||||
nav.style.display = '';
|
||||
});
|
||||
|
||||
return toolbar;
|
||||
};
|
||||
}
|
||||
|
||||
return unifiedMergeView(mergeConfig);
|
||||
},
|
||||
[original, showMergeControls, scrollToNextChunk]
|
||||
);
|
||||
|
||||
const buildExtensions = useCallback(() => {
|
||||
const extensions: Extension[] = [
|
||||
diffTheme,
|
||||
lineNumbers(),
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
EditorView.editable.of(!readOnly),
|
||||
EditorState.readOnly.of(readOnly),
|
||||
|
|
@ -339,77 +516,152 @@ export const CodeMirrorDiffView = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Unified merge view
|
||||
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
||||
original,
|
||||
highlightChanges: false,
|
||||
gutter: true,
|
||||
syntaxHighlightDeletions: true,
|
||||
};
|
||||
|
||||
if (collapseUnchangedProp) {
|
||||
mergeConfig.collapseUnchanged = {
|
||||
margin: collapseMargin,
|
||||
minSize: 4,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||
if (showMergeControls) {
|
||||
// NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge.
|
||||
// CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes.
|
||||
// When EditorView is recreated (e.g. from cached initialState), toDOM() returns
|
||||
// the OLD cached DOM whose `action` closure references the DESTROYED view.
|
||||
// Instead, we call acceptChunk/rejectChunk directly with viewRef.current.
|
||||
mergeConfig.mergeControls = (type, _action) => {
|
||||
const btn = document.createElement('button');
|
||||
|
||||
if (type === 'accept') {
|
||||
btn.textContent = '\u2713';
|
||||
btn.title = 'Accept change';
|
||||
btn.className = 'cm-merge-accept';
|
||||
btn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
const pos = view.posAtDOM(btn);
|
||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||
acceptChunk(view, pos);
|
||||
onAcceptRef.current?.(hunkIndex);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
btn.textContent = '\u2717';
|
||||
btn.title = 'Reject change';
|
||||
btn.className = 'cm-merge-reject';
|
||||
btn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
const pos = view.posAtDOM(btn);
|
||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||
rejectChunk(view, pos);
|
||||
onRejectRef.current?.(hunkIndex);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
};
|
||||
// Helper: position a chunkButtons container so it's below the change block,
|
||||
// but clamped to the visible viewport if that would be off-screen.
|
||||
const positionAtBottom = (chunkEl: Element, scroller: Element): void => {
|
||||
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
||||
if (!btnContainer) return;
|
||||
const parentRect = chunkEl.getBoundingClientRect();
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
// "below block" = 100% of parent height
|
||||
let targetY = parentRect.bottom;
|
||||
const tbHeight = btnContainer.offsetHeight || 28;
|
||||
// Clamp: if bottom edge would go below visible area, pin to viewport bottom
|
||||
if (targetY + tbHeight > scrollerRect.bottom) {
|
||||
targetY = scrollerRect.bottom - tbHeight;
|
||||
}
|
||||
|
||||
return btn;
|
||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||
};
|
||||
|
||||
const positionAtCursor = (chunkEl: Element, clientY: number, scroller: Element): void => {
|
||||
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
||||
if (!btnContainer) return;
|
||||
const parentRect = chunkEl.getBoundingClientRect();
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const tbHeight = btnContainer.offsetHeight || 28;
|
||||
let targetY = clientY - tbHeight / 2;
|
||||
// Clamp to viewport
|
||||
if (targetY + tbHeight > scrollerRect.bottom) {
|
||||
targetY = scrollerRect.bottom - tbHeight;
|
||||
}
|
||||
if (targetY < scrollerRect.top) {
|
||||
targetY = scrollerRect.top;
|
||||
}
|
||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||
};
|
||||
|
||||
// Find which chunk index the mouse is directly over (deleted or inserted area)
|
||||
const findHoveredChunkIndex = (event: MouseEvent, view: EditorView): number => {
|
||||
const el = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!el) return -1;
|
||||
const deletedChunk = el.closest('.cm-deletedChunk');
|
||||
if (deletedChunk) {
|
||||
const all = view.dom.querySelectorAll('.cm-deletedChunk');
|
||||
return [...all].indexOf(deletedChunk);
|
||||
}
|
||||
if (el.closest('.cm-changedLine, .cm-insertedLine')) {
|
||||
const allChunks = getChunks(view.state);
|
||||
if (!allChunks) return -1;
|
||||
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
||||
if (pos !== null) {
|
||||
for (let i = 0; i < allChunks.chunks.length; i++) {
|
||||
const chunk = allChunks.chunks[i];
|
||||
if (pos >= chunk.fromB && pos <= chunk.toB) return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// Find chunk nearest to cursor Y (for default "below block" display)
|
||||
const findNearestChunkIndex = (clientY: number, view: EditorView): number => {
|
||||
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
|
||||
let result = -1;
|
||||
if (allChunkEls.length > 0) {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
allChunkEls.forEach((el, idx) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const centerY = (rect.top + rect.bottom) / 2;
|
||||
const dist = Math.abs(clientY - centerY);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
result = bestIdx;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
extensions.push(
|
||||
EditorView.domEventHandlers({
|
||||
mousemove(event, view) {
|
||||
const allChunks = getChunks(view.state);
|
||||
if (allChunks && allChunks.chunks.length > 0) {
|
||||
const scroller = view.scrollDOM;
|
||||
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
|
||||
const hoveredIdx = findHoveredChunkIndex(event, view);
|
||||
const nearestIdx =
|
||||
hoveredIdx >= 0 ? hoveredIdx : findNearestChunkIndex(event.clientY, view);
|
||||
|
||||
const toolbars = view.dom.querySelectorAll('.cm-merge-toolbar');
|
||||
toolbars.forEach((tb, idx) => {
|
||||
tb.classList.toggle('cm-merge-toolbar-active', idx === nearestIdx);
|
||||
});
|
||||
|
||||
if (nearestIdx >= 0 && nearestIdx < allChunkEls.length) {
|
||||
const chunkEl = allChunkEls[nearestIdx] as HTMLElement;
|
||||
if (hoveredIdx >= 0) {
|
||||
positionAtCursor(chunkEl, event.clientY, scroller);
|
||||
} else {
|
||||
positionAtBottom(chunkEl, scroller);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
mouseleave(_event, view) {
|
||||
// Keep active toolbar visible, reposition to "below block"
|
||||
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
|
||||
if (activeToolbar) {
|
||||
const chunkEl = activeToolbar.closest('.cm-deletedChunk');
|
||||
if (chunkEl) positionAtBottom(chunkEl, view.scrollDOM);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Ensure at least one toolbar is visible (initial load + after accept/reject)
|
||||
extensions.push(
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.view.dom.querySelector('.cm-merge-toolbar-active')) return;
|
||||
requestAnimationFrame(() => {
|
||||
const v = update.view;
|
||||
if (v.dom.querySelector('.cm-merge-toolbar-active')) return;
|
||||
const first = v.dom.querySelector('.cm-merge-toolbar');
|
||||
if (first) {
|
||||
first.classList.add('cm-merge-toolbar-active');
|
||||
const chunkEl = first.closest('.cm-deletedChunk');
|
||||
if (chunkEl) positionAtBottom(chunkEl, v.scrollDOM);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
extensions.push(unifiedMergeView(mergeConfig));
|
||||
// Unified merge view (wrapped in compartment for dynamic collapse reconfigure)
|
||||
extensions.push(
|
||||
mergeCompartment.current.of(
|
||||
buildMergeExtension(collapseRef.current.enabled, collapseRef.current.margin)
|
||||
)
|
||||
);
|
||||
|
||||
return extensions;
|
||||
}, [
|
||||
original,
|
||||
readOnly,
|
||||
showMergeControls,
|
||||
collapseUnchangedProp,
|
||||
collapseMargin,
|
||||
scrollToNextChunk,
|
||||
]);
|
||||
}, [readOnly, showMergeControls, buildMergeExtension]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
|
@ -477,6 +729,17 @@ export const CodeMirrorDiffView = ({
|
|||
};
|
||||
}, [fileName, buildExtensions, initialState]);
|
||||
|
||||
// Dynamic collapse toggle — reconfigure compartment in-place, preserving undo history
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: mergeCompartment.current.reconfigure(
|
||||
buildMergeExtension(collapseUnchangedProp, collapseMargin)
|
||||
),
|
||||
});
|
||||
}, [collapseUnchangedProp, collapseMargin, buildMergeExtension]);
|
||||
|
||||
// Auto-viewed detection via IntersectionObserver
|
||||
useEffect(() => {
|
||||
if (!endSentinelRef.current || !onFullyViewed) return;
|
||||
|
|
|
|||
|
|
@ -140,12 +140,7 @@ export const ReviewToolbar = ({
|
|||
Accept All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>Accept all changes in current file</span>
|
||||
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||
⌘Y
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
<TooltipContent side="bottom">Accept all changes in current file</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
|
@ -158,12 +153,7 @@ export const ReviewToolbar = ({
|
|||
Reject All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>Reject all changes in current file</span>
|
||||
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||
⌘N
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
<TooltipContent side="bottom">Reject all changes in current file</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { get, set } from 'idb-keyval';
|
||||
|
||||
const IDB_KEY = 'comment-read-state';
|
||||
const LS_KEY = 'comment-read-state';
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
|
|
@ -8,13 +9,14 @@ type ReadState = Record<string, number>; // key = "teamName/taskId", value = tim
|
|||
|
||||
let cache: ReadState = {};
|
||||
let loaded = false;
|
||||
let idbAvailable = true; // flips to false on first IndexedDB failure
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// --- useSyncExternalStore API ---
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (!loaded) void loadFromIdb();
|
||||
if (!loaded) void load();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
|
|
@ -46,6 +48,28 @@ export function getUnreadCount(
|
|||
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
|
||||
}
|
||||
|
||||
// --- localStorage fallback ---
|
||||
function lsLoad(): ReadState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as ReadState)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lsSave(state: ReadState): void {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// localStorage full or unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
function hasIndexedDB(): boolean {
|
||||
return typeof indexedDB !== 'undefined';
|
||||
|
|
@ -59,54 +83,79 @@ function scheduleSave(): void {
|
|||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
void saveToIdb();
|
||||
void save();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function loadFromIdb(): Promise<void> {
|
||||
async function load(): Promise<void> {
|
||||
if (loaded) return;
|
||||
if (!hasIndexedDB()) {
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = await get<ReadState>(IDB_KEY);
|
||||
if (stored && typeof stored === 'object') {
|
||||
cache = { ...stored, ...cache }; // merge: in-memory wins over stale IDB
|
||||
notify();
|
||||
|
||||
// Try IndexedDB first
|
||||
if (hasIndexedDB() && idbAvailable) {
|
||||
try {
|
||||
const stored = await get<ReadState>(IDB_KEY);
|
||||
if (stored && typeof stored === 'object') {
|
||||
cache = { ...stored, ...cache };
|
||||
notify();
|
||||
}
|
||||
loaded = true;
|
||||
return;
|
||||
} catch {
|
||||
// IndexedDB broken — fall back to localStorage silently
|
||||
idbAvailable = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[commentReadStorage] load failed:', e);
|
||||
}
|
||||
|
||||
// Fallback: localStorage
|
||||
const stored = lsLoad();
|
||||
if (stored) {
|
||||
cache = { ...stored, ...cache };
|
||||
notify();
|
||||
}
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function saveToIdb(): Promise<void> {
|
||||
if (!hasIndexedDB()) return;
|
||||
try {
|
||||
await set(IDB_KEY, cache);
|
||||
} catch (e) {
|
||||
console.error('[commentReadStorage] save failed:', e);
|
||||
async function save(): Promise<void> {
|
||||
if (idbAvailable && hasIndexedDB()) {
|
||||
try {
|
||||
await set(IDB_KEY, cache);
|
||||
return;
|
||||
} catch {
|
||||
idbAvailable = false;
|
||||
}
|
||||
}
|
||||
lsSave(cache);
|
||||
}
|
||||
|
||||
export async function cleanupStale(): Promise<void> {
|
||||
if (!hasIndexedDB()) return;
|
||||
try {
|
||||
const stored = await get<ReadState>(IDB_KEY);
|
||||
if (!stored) return;
|
||||
const now = Date.now();
|
||||
const cleaned: ReadState = {};
|
||||
const now = Date.now();
|
||||
const clean = (state: ReadState): { cleaned: ReadState; changed: boolean } => {
|
||||
const result: ReadState = {};
|
||||
let changed = false;
|
||||
for (const [k, v] of Object.entries(stored)) {
|
||||
for (const [k, v] of Object.entries(state)) {
|
||||
if (now - v < STALE_THRESHOLD_MS) {
|
||||
cleaned[k] = v;
|
||||
result[k] = v;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await set(IDB_KEY, cleaned);
|
||||
} catch (e) {
|
||||
console.error('[commentReadStorage] cleanup failed:', e);
|
||||
return { cleaned: result, changed };
|
||||
};
|
||||
|
||||
if (idbAvailable && hasIndexedDB()) {
|
||||
try {
|
||||
const stored = await get<ReadState>(IDB_KEY);
|
||||
if (!stored) return;
|
||||
const { cleaned, changed } = clean(stored);
|
||||
if (changed) await set(IDB_KEY, cleaned);
|
||||
return;
|
||||
} catch {
|
||||
idbAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
const stored = lsLoad();
|
||||
if (!stored) return;
|
||||
const { cleaned, changed } = clean(stored);
|
||||
if (changed) lsSave(cleaned);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,17 @@ export function initializeNotificationListeners(): () => void {
|
|||
|
||||
if (api.teams?.onTeamChange) {
|
||||
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
||||
// Immediate in-memory update for lead activity — no filesystem refresh needed
|
||||
if (event.type === 'lead-activity' && event.detail) {
|
||||
useStore.setState((prev) => ({
|
||||
leadActivityByTeam: {
|
||||
...prev.leadActivityByTeam,
|
||||
[event.teamName]: event.detail as 'active' | 'idle' | 'offline',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
||||
if (!teamListRefreshTimer) {
|
||||
teamListRefreshTimer = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
|
|
@ -61,6 +62,7 @@ export interface TeamSlice {
|
|||
lastSendMessageResult: SendMessageResult | null;
|
||||
reviewActionError: string | null;
|
||||
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
||||
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||
activeProvisioningRunId: string | null;
|
||||
provisioningError: string | null;
|
||||
kanbanFilterQuery: string | null;
|
||||
|
|
@ -120,6 +122,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
lastSendMessageResult: null,
|
||||
reviewActionError: null,
|
||||
provisioningRuns: {},
|
||||
leadActivityByTeam: {},
|
||||
activeProvisioningRunId: null,
|
||||
provisioningError: null,
|
||||
kanbanFilterQuery: null,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import type { MemberStatus, ResolvedTeamMember, TeamTaskStatus } from '@shared/types';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
|
||||
import type {
|
||||
LeadActivityState,
|
||||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamTaskStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
export function agentAvatarUrl(name: string, size = 64): string {
|
||||
return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
|
||||
|
|
@ -14,11 +21,17 @@ export const STATUS_DOT_COLORS: Record<MemberStatus, string> = {
|
|||
export function getMemberDotClass(
|
||||
member: ResolvedTeamMember,
|
||||
isTeamAlive?: boolean,
|
||||
isTeamProvisioning?: boolean
|
||||
isTeamProvisioning?: boolean,
|
||||
leadActivity?: LeadActivityState
|
||||
): string {
|
||||
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
|
||||
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
|
||||
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
|
||||
if (leadActivity && member.agentType === 'team-lead') {
|
||||
return leadActivity === 'active'
|
||||
? `${STATUS_DOT_COLORS.active} animate-pulse`
|
||||
: STATUS_DOT_COLORS.active;
|
||||
}
|
||||
if (member.status === 'unknown') return STATUS_DOT_COLORS.unknown;
|
||||
if (member.currentTaskId) return STATUS_DOT_COLORS.active;
|
||||
return member.status === 'active' ? STATUS_DOT_COLORS.active : STATUS_DOT_COLORS.idle;
|
||||
|
|
@ -27,11 +40,15 @@ export function getMemberDotClass(
|
|||
export function getPresenceLabel(
|
||||
member: ResolvedTeamMember,
|
||||
isTeamAlive?: boolean,
|
||||
isTeamProvisioning?: boolean
|
||||
isTeamProvisioning?: boolean,
|
||||
leadActivity?: LeadActivityState
|
||||
): string {
|
||||
if (member.status === 'terminated') return 'terminated';
|
||||
if (isTeamProvisioning) return 'connecting';
|
||||
if (isTeamAlive === false) return 'offline';
|
||||
if (leadActivity && member.agentType === 'team-lead') {
|
||||
return leadActivity === 'active' ? 'processing' : 'ready';
|
||||
}
|
||||
if (member.status === 'unknown') return 'idle';
|
||||
return member.currentTaskId ? 'working' : 'idle';
|
||||
}
|
||||
|
|
@ -50,6 +67,43 @@ export const TASK_STATUS_LABELS: Record<TeamTaskStatus, string> = {
|
|||
deleted: 'Deleted',
|
||||
};
|
||||
|
||||
interface MemberColorInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
removedAt?: number | string | null;
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a consistent 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<
|
||||
'review' | 'approved',
|
||||
{ label: string; bg: string; text: string }
|
||||
|
|
|
|||
|
|
@ -3,17 +3,8 @@
|
|||
* Used during team creation and for preview in the UI.
|
||||
* Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length].
|
||||
*/
|
||||
export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const;
|
||||
export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'purple', 'red'] as const;
|
||||
|
||||
export function getMemberColor(index: number): string {
|
||||
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
|
||||
}
|
||||
|
||||
/** Derive a stable fallback color from a member name (position-independent). */
|
||||
export function getMemberColorByName(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return MEMBER_COLOR_PALETTE[Math.abs(hash) % MEMBER_COLOR_PALETTE.length];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type {
|
|||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
SendMessageRequest,
|
||||
|
|
@ -425,6 +426,7 @@ export interface TeamsAPI {
|
|||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||
onProvisioningProgress: (
|
||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||
|
|
|
|||
|
|
@ -204,8 +204,10 @@ export interface CreateTaskRequest {
|
|||
startImmediately?: boolean;
|
||||
}
|
||||
|
||||
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
export interface TeamChangeEvent {
|
||||
type: 'config' | 'inbox' | 'task';
|
||||
type: 'config' | 'inbox' | 'task' | 'lead-activity';
|
||||
teamName: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
|
||||
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
|
||||
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
|
||||
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
|
||||
}));
|
||||
|
||||
import {
|
||||
|
|
@ -72,6 +73,7 @@ import {
|
|||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_UPDATE_MEMBER_ROLE,
|
||||
} from '../../../src/preload/constants/ipcChannels';
|
||||
|
|
@ -134,6 +136,7 @@ describe('ipc teams handlers', () => {
|
|||
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||
getLiveLeadProcessMessages: vi.fn(() => []),
|
||||
getAliveTeams: vi.fn(() => ['my-team']),
|
||||
getLeadActivityState: vi.fn(() => 'idle'),
|
||||
stopTeam: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
|
|
@ -174,6 +177,7 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
||||
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
||||
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(true);
|
||||
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns success false on invalid sendMessage args', async () => {
|
||||
|
|
@ -482,5 +486,6 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(false);
|
||||
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
||||
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
||||
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue