feat: enhance task messaging and member role detection
- Introduced a new function `quoteMarkdown` to format task comments with markdown quotes for better readability. - Updated the `buildCommentNotificationMessage` to utilize `quoteMarkdown`, ensuring comments are displayed correctly. - Refactored member role detection across multiple services to use `isLeadMember` for consistency and clarity in identifying team leads. - Enhanced various components to improve handling of team member roles, ensuring accurate representation in UI and logic. - Adjusted tests to reflect changes in comment formatting and member role checks, improving overall reliability.
This commit is contained in:
parent
2f73682ff1
commit
194bd1bf1e
29 changed files with 669 additions and 415 deletions
|
|
@ -28,6 +28,13 @@ function isSameTaskMember(left, right, leadName) {
|
|||
);
|
||||
}
|
||||
|
||||
function quoteMarkdown(text) {
|
||||
return String(text)
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildAssignmentMessage(context, task, options = {}) {
|
||||
const description =
|
||||
typeof options.description === 'string' && options.description.trim()
|
||||
|
|
@ -74,7 +81,7 @@ function buildCommentNotificationMessage(context, task, comment) {
|
|||
`**Comment on task ${taskLabel}**`,
|
||||
`> ${task.subject}`,
|
||||
``,
|
||||
comment.text,
|
||||
quoteMarkdown(comment.text),
|
||||
``,
|
||||
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
||||
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ describe('agent-teams-controller API', () => {
|
|||
timestamp: '2026-02-23T11:00:00.000Z',
|
||||
read: false,
|
||||
text:
|
||||
`**Comment on task #${task.displayId}**\n> Ship migration\n\nHeads up\n\n` +
|
||||
`**Comment on task #${task.displayId}**\n> Ship migration\n\n> Heads up\n\n` +
|
||||
'<agent-block>\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n</agent-block>',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
||||
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
|
@ -194,7 +195,7 @@ export class CrossTeamService {
|
|||
}
|
||||
if (!config || config.deletedAt) continue;
|
||||
|
||||
const lead = config.members?.find((m) => m.role === 'lead' || m.name === 'team-lead');
|
||||
const lead = config.members?.find((m) => isLeadMember(m));
|
||||
|
||||
targets.push({
|
||||
teamName: entry,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
createCliAutoSuffixNameGuard,
|
||||
|
|
@ -207,7 +208,7 @@ export class TeamConfigReader {
|
|||
const name = m.name?.trim();
|
||||
if (!name) return;
|
||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
||||
if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return;
|
||||
if (name === 'user' || isLeadMember(m)) return;
|
||||
const key = name.toLowerCase();
|
||||
// If meta marks this name removed, do not surface it in summaries
|
||||
if (removedKeys.has(key)) return;
|
||||
|
|
@ -227,7 +228,7 @@ export class TeamConfigReader {
|
|||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
||||
if (name === 'team-lead' || name === 'user' || member.agentType === 'team-lead') continue;
|
||||
if (name === 'user' || isLeadMember(member)) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (member.removedAt) {
|
||||
removedKeys.add(key);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
stripAgentBlocks,
|
||||
} from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
|
@ -585,7 +586,7 @@ export class TeamDataService {
|
|||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
|
||||
const leadEntry = config.members?.find((m) => m.name === 'team-lead');
|
||||
const leadEntry = config.members?.find((m) => isLeadMember(m));
|
||||
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
||||
if (!leadCwd) return;
|
||||
|
||||
|
|
@ -737,7 +738,7 @@ export class TeamDataService {
|
|||
): Promise<{ oldRole: string | undefined; changed: boolean }> {
|
||||
const { members, member } = await this.ensureMemberInMeta(teamName, memberName);
|
||||
if (member.removedAt) throw new Error(`Member "${memberName}" is removed`);
|
||||
if (member.agentType === 'team-lead') throw new Error('Cannot change team lead role');
|
||||
if (isLeadAgentType(member.agentType)) throw new Error('Cannot change team lead role');
|
||||
|
||||
const oldRole = member.role;
|
||||
const normalized = typeof newRole === 'string' && newRole.trim() ? newRole.trim() : undefined;
|
||||
|
|
@ -753,9 +754,7 @@ export class TeamDataService {
|
|||
request: { members: { name: string; role?: string; workflow?: string }[] }
|
||||
): Promise<void> {
|
||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||
const isTeamLead = (m: TeamMember): boolean =>
|
||||
m.agentType === 'team-lead' || m.name.trim().toLowerCase() === 'team-lead';
|
||||
const existingLead = existing.find(isTeamLead) ?? null;
|
||||
const existingLead = existing.find(isLeadMember) ?? null;
|
||||
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
||||
const joinedAt = Date.now();
|
||||
const nextByName = new Set<string>();
|
||||
|
|
@ -788,7 +787,7 @@ export class TeamDataService {
|
|||
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||
const nextRemoved: TeamMember[] = [];
|
||||
for (const prev of existing) {
|
||||
if (isTeamLead(prev)) continue;
|
||||
if (isLeadMember(prev)) continue;
|
||||
const prevName = prev.name.trim();
|
||||
if (!prevName) continue;
|
||||
const key = prevName.toLowerCase();
|
||||
|
|
@ -815,7 +814,7 @@ export class TeamDataService {
|
|||
if (member.removedAt) {
|
||||
throw new Error(`Member "${memberName}" is already removed`);
|
||||
}
|
||||
if (member.agentType === 'team-lead') {
|
||||
if (isLeadAgentType(member.agentType)) {
|
||||
throw new Error('Cannot remove team lead');
|
||||
}
|
||||
|
||||
|
|
@ -1544,16 +1543,14 @@ export class TeamDataService {
|
|||
|
||||
// Check config.json members first (Claude Code-created teams)
|
||||
if (config?.members?.length) {
|
||||
const lead = config.members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
||||
);
|
||||
const lead = config.members.find((m) => isLeadMember(m));
|
||||
if (lead?.name) return lead.name;
|
||||
}
|
||||
|
||||
// Fallback: check members.meta.json (UI-created teams)
|
||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
if (metaMembers.length > 0) {
|
||||
const lead = metaMembers.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead');
|
||||
const lead = metaMembers.find((m) => isLeadMember(m));
|
||||
if (lead?.name) return lead.name;
|
||||
return metaMembers[0]?.name ?? null;
|
||||
}
|
||||
|
|
@ -1844,7 +1841,7 @@ export class TeamDataService {
|
|||
return [];
|
||||
}
|
||||
|
||||
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
|
||||
const leadName = config.members?.find((m) => isLeadAgentType(m.agentType))?.name ?? 'team-lead';
|
||||
const sessionIds = this.getRecentLeadSessionIds(config);
|
||||
if (sessionIds.length === 0) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
||||
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||
import { createReadStream } from 'fs';
|
||||
|
|
@ -109,7 +110,7 @@ export class TeamMemberLogsFinder {
|
|||
const results: MemberLogSummary[] = [];
|
||||
|
||||
const leadMemberName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
|
||||
if (isLeadMember && config.leadSessionId) {
|
||||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||
const leadSummary = await this.parseLeadSessionSummary(
|
||||
|
|
@ -200,7 +201,7 @@ export class TeamMemberLogsFinder {
|
|||
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
||||
const results: MemberLogSummary[] = [];
|
||||
const leadMemberName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
|
||||
|
||||
if (config.leadSessionId) {
|
||||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||
|
|
@ -422,7 +423,7 @@ export class TeamMemberLogsFinder {
|
|||
const refs: { filePath: string; memberName: string; sortTime: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const leadMemberName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
|
||||
|
||||
const pushRef = (filePath: string, memberName: string, sortTime = 0): void => {
|
||||
const key = `${memberName.toLowerCase()}:${filePath}`;
|
||||
|
|
@ -817,7 +818,7 @@ export class TeamMemberLogsFinder {
|
|||
if (!discovery) return null;
|
||||
const { config } = discovery;
|
||||
const leadMemberName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
|
||||
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
|
||||
return { ...discovery, isLeadMember };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
|||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||
|
|
@ -3582,7 +3583,7 @@ export class TeamProvisioningService {
|
|||
if (!config) return 0;
|
||||
|
||||
const leadName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
|
||||
|
||||
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
||||
try {
|
||||
|
|
@ -4855,11 +4856,11 @@ export class TeamProvisioningService {
|
|||
try {
|
||||
const config = await this.configReader.getConfig(run.teamName);
|
||||
if (config?.members) {
|
||||
const configLead = config.members.find((m) => m?.agentType === 'team-lead');
|
||||
const configLead = config.members.find((m) => isLeadAgentType(m?.agentType));
|
||||
leadName = configLead?.name?.trim() || 'team-lead';
|
||||
// Convert config members (excluding lead) to TeamCreateRequest member format.
|
||||
currentMembers = config.members
|
||||
.filter((m) => m?.agentType !== 'team-lead' && m?.name)
|
||||
.filter((m) => !isLeadAgentType(m?.agentType) && m?.name)
|
||||
.map((m) => ({
|
||||
name: m.name,
|
||||
role: m.role ?? undefined,
|
||||
|
|
@ -5310,7 +5311,8 @@ export class TeamProvisioningService {
|
|||
members?: { name?: string; agentType?: string }[];
|
||||
};
|
||||
const suffixed = (config.members ?? []).filter(
|
||||
(m) => typeof m.name === 'string' && /-\d+$/.test(m.name) && m.agentType !== 'team-lead'
|
||||
(m) =>
|
||||
typeof m.name === 'string' && /-\d+$/.test(m.name) && !isLeadAgentType(m.agentType)
|
||||
);
|
||||
if (suffixed.length > 0) {
|
||||
logger.warn(
|
||||
|
|
@ -6155,7 +6157,7 @@ export class TeamProvisioningService {
|
|||
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||
const agentType = typeof m.agentType === 'string' ? m.agentType : '';
|
||||
if (!name) continue;
|
||||
if (agentType === 'team-lead' || name === 'team-lead' || name === 'user') {
|
||||
if (isLeadMember(m) || name === 'user') {
|
||||
nextMembers.push(m);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -6196,7 +6198,7 @@ export class TeamProvisioningService {
|
|||
const name = m.name?.trim() ?? '';
|
||||
if (!name) return false;
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'team-lead' || lower === 'user' || m.agentType === 'team-lead') return true;
|
||||
if (lower === 'user' || isLeadMember(m)) return true;
|
||||
if (!m.removedAt && !keepName(name)) {
|
||||
removedFromMeta.push(name);
|
||||
return false;
|
||||
|
|
@ -6299,9 +6301,8 @@ export class TeamProvisioningService {
|
|||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
if (!name) continue;
|
||||
const lower = name.toLowerCase();
|
||||
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
|
||||
|
||||
if (agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') continue;
|
||||
if (isLeadMember(member) || lower === 'user') continue;
|
||||
|
||||
const leadAgentId = config.leadAgentId;
|
||||
if (
|
||||
|
|
@ -6343,7 +6344,7 @@ export class TeamProvisioningService {
|
|||
// Keep only the lead entry.
|
||||
const leadMembers = members.filter((member) => {
|
||||
const agentType = member.agentType;
|
||||
if (typeof agentType === 'string' && agentType === 'team-lead') {
|
||||
if (typeof agentType === 'string' && isLeadAgentType(agentType)) {
|
||||
return true;
|
||||
}
|
||||
const leadAgentId = config.leadAgentId;
|
||||
|
|
@ -6381,7 +6382,7 @@ export class TeamProvisioningService {
|
|||
if (
|
||||
name &&
|
||||
agentType &&
|
||||
agentType !== 'team-lead' &&
|
||||
!isLeadAgentType(agentType) &&
|
||||
name !== 'team-lead' &&
|
||||
name !== 'user'
|
||||
) {
|
||||
|
|
@ -6652,7 +6653,7 @@ export class TeamProvisioningService {
|
|||
for (const member of metaMembers) {
|
||||
const rawName = member.name?.trim() ?? '';
|
||||
const lower = rawName.toLowerCase();
|
||||
if (member.agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') {
|
||||
if (isLeadMember(member) || lower === 'user') {
|
||||
continue;
|
||||
}
|
||||
const name = rawName;
|
||||
|
|
@ -6771,13 +6772,7 @@ export class TeamProvisioningService {
|
|||
for (const member of parsed.members) {
|
||||
const rawName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||
const lower = rawName.toLowerCase();
|
||||
if (
|
||||
!member ||
|
||||
member.agentType === 'team-lead' ||
|
||||
lower === 'team-lead' ||
|
||||
lower === 'user'
|
||||
)
|
||||
continue;
|
||||
if (!member || isLeadMember(member) || lower === 'user') continue;
|
||||
const name = rawName;
|
||||
if (!name) continue;
|
||||
byName.set(name, { name });
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import * as fs from 'node:fs';
|
|||
import * as path from 'node:path';
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
interface ListTeamsPayload {
|
||||
teamsDir: string;
|
||||
largeConfigBytes: number;
|
||||
|
|
@ -263,7 +265,7 @@ function mergeMember(
|
|||
): void {
|
||||
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||
if (!name) return;
|
||||
if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return;
|
||||
if (name === 'user' || isLeadMember(m)) return;
|
||||
const key = name.toLowerCase();
|
||||
if (removedKeys.has(key)) return;
|
||||
const existing = memberMap.get(key);
|
||||
|
|
@ -433,7 +435,7 @@ async function listTeams(
|
|||
if (!isRawMember(member)) continue;
|
||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
if (!name) continue;
|
||||
if (name === 'team-lead' || member.agentType === 'team-lead') continue;
|
||||
if (isLeadMember(member)) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (member.removedAt) {
|
||||
removedKeys.add(key);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function getTaskUnreadCount(
|
|||
readState: ReturnType<typeof getSnapshot>,
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
comments: { createdAt: string }[] | undefined
|
||||
comments: { id?: string; createdAt: string }[] | undefined
|
||||
): number {
|
||||
return getUnreadCount(readState, teamName, taskId, comments ?? []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||
|
|
@ -693,7 +694,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}, [filteredTasks, kanbanSearch]);
|
||||
|
||||
const activeTeammateCount = useMemo(
|
||||
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
||||
() => activeMembers.filter((m) => !isLeadMember(m)).length,
|
||||
[activeMembers]
|
||||
);
|
||||
|
||||
|
|
@ -1744,7 +1745,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
currentName={data.config.name}
|
||||
currentDescription={data.config.description ?? ''}
|
||||
currentColor={data.config.color ?? ''}
|
||||
currentMembers={data.members.filter((m) => m.agentType !== 'team-lead')}
|
||||
currentMembers={data.members.filter((m) => !isLeadAgentType(m.agentType))}
|
||||
projectPath={data.config.projectPath}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onSaved={() => void selectTeam(teamName)}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
|||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
|
|
@ -38,12 +39,19 @@ import {
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||
import { TeamEmptyState } from './TeamEmptyState';
|
||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||
|
||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
||||
import type { TeamCreateRequest, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||
import type {
|
||||
ResolvedTeamMember,
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
TeamSummary,
|
||||
TeamSummaryMember,
|
||||
} from '@shared/types';
|
||||
|
||||
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
||||
const base = sourceName.replace(/-\d+$/, '');
|
||||
|
|
@ -467,7 +475,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const existingNames = teams.map((t) => t.teamName);
|
||||
const uniqueName = generateUniqueName(teamName, existingNames);
|
||||
const members = (data.members ?? [])
|
||||
.filter((m) => !m.removedAt && m.agentType !== 'team-lead')
|
||||
.filter((m) => !m.removedAt && !isLeadAgentType(m.agentType))
|
||||
.map((m) => {
|
||||
let role = m.role;
|
||||
if (!role && m.agentType && m.agentType !== 'general-purpose') {
|
||||
|
|
@ -505,14 +513,39 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
}, []);
|
||||
|
||||
const [launchingTeamName, setLaunchingTeamName] = useState<string | null>(null);
|
||||
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
||||
const [launchDialogTeamName, setLaunchDialogTeamName] = useState('');
|
||||
const [launchDialogMembers, setLaunchDialogMembers] = useState<ResolvedTeamMember[]>([]);
|
||||
const [launchDialogDefaultPath, setLaunchDialogDefaultPath] = useState<string | undefined>();
|
||||
|
||||
const handleLaunchTeam = useCallback(
|
||||
async (teamName: string, projectPath: string | undefined, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!projectPath) return;
|
||||
setLaunchingTeamName(teamName);
|
||||
try {
|
||||
await launchTeam({ teamName, cwd: projectPath });
|
||||
openTeamTab(teamName, projectPath);
|
||||
const data = await api.teams.getData(teamName);
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers(data.members ?? []);
|
||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||
setLaunchDialogOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to load team data for launch dialog:', err);
|
||||
// Fallback: open dialog with minimal data
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers([]);
|
||||
setLaunchDialogDefaultPath(projectPath);
|
||||
setLaunchDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleLaunchSubmit = useCallback(
|
||||
async (request: TeamLaunchRequest) => {
|
||||
setLaunchingTeamName(request.teamName);
|
||||
try {
|
||||
await launchTeam(request);
|
||||
openTeamTab(request.teamName, request.cwd);
|
||||
} catch (err) {
|
||||
console.error('Failed to launch team:', err);
|
||||
} finally {
|
||||
|
|
@ -587,6 +620,21 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
/>
|
||||
);
|
||||
|
||||
const launchDialogElement = (
|
||||
<LaunchTeamDialog
|
||||
mode="launch"
|
||||
open={launchDialogOpen}
|
||||
teamName={launchDialogTeamName}
|
||||
members={launchDialogMembers}
|
||||
defaultProjectPath={launchDialogDefaultPath}
|
||||
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
activeTeams={activeTeams}
|
||||
onClose={() => setLaunchDialogOpen(false)}
|
||||
onLaunch={handleLaunchSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderHeader = (): React.JSX.Element => (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -979,6 +1027,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
{renderHeader()}
|
||||
{renderContent()}
|
||||
{createDialogElement}
|
||||
{launchDialogElement}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,21 +13,14 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { TiptapEditor } from '@renderer/components/ui/tiptap';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
|
|
@ -36,7 +29,7 @@ import {
|
|||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, Search } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -99,6 +92,7 @@ export const CreateTaskDialog = ({
|
|||
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
||||
const [blockedBySearch, setBlockedBySearch] = useState('');
|
||||
const [relatedSearch, setRelatedSearch] = useState('');
|
||||
const [showOptionalFields, setShowOptionalFields] = useState(false);
|
||||
const prevOpenRef = useRef(false);
|
||||
|
||||
// Reset form when dialog opens (avoid setState during render)
|
||||
|
|
@ -124,6 +118,7 @@ export const CreateTaskDialog = ({
|
|||
promptDraft.clearDraft();
|
||||
setBlockedBySearch('');
|
||||
setRelatedSearch('');
|
||||
setShowOptionalFields(false);
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
}, [
|
||||
|
|
@ -170,14 +165,6 @@ export const CreateTaskDialog = ({
|
|||
);
|
||||
};
|
||||
|
||||
const handleDescChipRemove = (chipId: string): void => {
|
||||
const chip = descChipDraft.chips.find((c) => c.id === chipId);
|
||||
if (chip) {
|
||||
descriptionDraft.setValue(removeChipTokenFromText(descriptionDraft.value, chip));
|
||||
}
|
||||
descChipDraft.setChips(descChipDraft.chips.filter((c) => c.id !== chipId));
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!canSubmit) return;
|
||||
const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim());
|
||||
|
|
@ -214,44 +201,19 @@ export const CreateTaskDialog = ({
|
|||
<Label className={requiresOwner ? undefined : 'label-optional'}>
|
||||
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
|
||||
</Label>
|
||||
<Select
|
||||
value={owner || '__unassigned__'}
|
||||
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={requiresOwner ? 'Select a member' : 'Unassigned'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
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">
|
||||
{memberColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: memberColor.border }}
|
||||
/>
|
||||
) : null}
|
||||
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
||||
{m.name}
|
||||
</span>
|
||||
{role ? <span className="text-[var(--color-text-muted)]">({role})</span> : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MemberSelect
|
||||
members={members}
|
||||
value={owner || null}
|
||||
onChange={(v) => setOwner(v ?? '')}
|
||||
placeholder={requiresOwner ? 'Select a member' : 'Select member...'}
|
||||
allowUnassigned={!requiresOwner}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogContent className="sm:max-w-[580px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -294,51 +256,199 @@ export const CreateTaskDialog = ({
|
|||
|
||||
{assigneeField}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="task-description" className="label-optional">
|
||||
Description (optional)
|
||||
</Label>
|
||||
<MentionableTextarea
|
||||
id="task-description"
|
||||
placeholder="Task details..."
|
||||
value={descriptionDraft.value}
|
||||
onValueChange={descriptionDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
chips={descChipDraft.chips}
|
||||
onChipRemove={handleDescChipRemove}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={(chip) => descChipDraft.setChips([...descChipDraft.chips, chip])}
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
descriptionDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Toggle button for optional fields */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setShowOptionalFields((prev) => !prev)}
|
||||
>
|
||||
{showOptionalFields ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{showOptionalFields ? 'Hide optional fields' : 'Show optional fields'}</span>
|
||||
</button>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="task-prompt" className="label-optional">
|
||||
Prompt for assignee (optional)
|
||||
</Label>
|
||||
<MentionableTextarea
|
||||
id="task-prompt"
|
||||
placeholder="Custom instructions for the team member..."
|
||||
value={promptDraft.value}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
projectPath={projectPath}
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/* Collapsible optional fields */}
|
||||
<div
|
||||
className="grid overflow-hidden transition-all duration-200 ease-in-out"
|
||||
style={{ gridTemplateRows: showOptionalFields ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="label-optional">Description (optional)</Label>
|
||||
<TiptapEditor
|
||||
content={descriptionDraft.value}
|
||||
onChange={descriptionDraft.setValue}
|
||||
placeholder="Task details (supports markdown)"
|
||||
minHeight="100px"
|
||||
maxHeight="200px"
|
||||
toolbar
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="task-prompt" className="label-optional">
|
||||
Prompt for assignee (optional)
|
||||
</Label>
|
||||
<MentionableTextarea
|
||||
id="task-prompt"
|
||||
placeholder="Custom instructions for the team member..."
|
||||
value={promptDraft.value}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
projectPath={projectPath}
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{availableTasks.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="label-optional">Blocked by tasks (optional)</Label>
|
||||
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{availableTasks.length > 3 ? (
|
||||
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={blockedBySearch}
|
||||
onChange={(e) => setBlockedBySearch(e.target.value)}
|
||||
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||
{availableTasks
|
||||
.filter(
|
||||
(t) =>
|
||||
!blockedBySearch ||
|
||||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
|
||||
t.id.includes(blockedBySearch) ||
|
||||
t.displayId?.includes(blockedBySearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = blockedBy.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-500/15 text-blue-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleBlockedBy(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-blue-400 bg-blue-500/30 text-blue-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
{formatTaskDisplayLabel(t)}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{blockedBy.length > 0 ? (
|
||||
<p className="text-[11px] text-yellow-300">
|
||||
Task will be blocked by:{' '}
|
||||
{blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableTasks.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="label-optional">Related tasks (optional)</Label>
|
||||
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{availableTasks.length > 3 ? (
|
||||
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={relatedSearch}
|
||||
onChange={(e) => setRelatedSearch(e.target.value)}
|
||||
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||
{availableTasks
|
||||
.filter(
|
||||
(t) =>
|
||||
!relatedSearch ||
|
||||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
|
||||
t.id.includes(relatedSearch) ||
|
||||
t.displayId?.includes(relatedSearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = related.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={`related:${t.id}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-purple-500/15 text-purple-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleRelated(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-purple-400 bg-purple-500/30 text-purple-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
{formatTaskDisplayLabel(t)}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{related.length > 0 ? (
|
||||
<p className="text-[11px] text-purple-300">
|
||||
Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{owner ? (
|
||||
|
|
@ -364,147 +474,6 @@ export const CreateTaskDialog = ({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableTasks.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="label-optional">Blocked by tasks (optional)</Label>
|
||||
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{availableTasks.length > 3 ? (
|
||||
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={blockedBySearch}
|
||||
onChange={(e) => setBlockedBySearch(e.target.value)}
|
||||
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||
{availableTasks
|
||||
.filter(
|
||||
(t) =>
|
||||
!blockedBySearch ||
|
||||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
|
||||
t.id.includes(blockedBySearch) ||
|
||||
t.displayId?.includes(blockedBySearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = blockedBy.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-500/15 text-blue-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleBlockedBy(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-blue-400 bg-blue-500/30 text-blue-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
{formatTaskDisplayLabel(t)}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{blockedBy.length > 0 ? (
|
||||
<p className="text-[11px] text-yellow-300">
|
||||
Task will be blocked by:{' '}
|
||||
{blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableTasks.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="label-optional">Related tasks (optional)</Label>
|
||||
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{availableTasks.length > 3 ? (
|
||||
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={relatedSearch}
|
||||
onChange={(e) => setRelatedSearch(e.target.value)}
|
||||
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||
{availableTasks
|
||||
.filter(
|
||||
(t) =>
|
||||
!relatedSearch ||
|
||||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
|
||||
t.id.includes(relatedSearch) ||
|
||||
t.displayId?.includes(relatedSearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = related.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={`related:${t.id}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-purple-500/15 text-purple-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleRelated(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-purple-400 bg-purple-500/30 text-purple-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
{formatTaskDisplayLabel(t)}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{related.length > 0 ? (
|
||||
<p className="text-[11px] text-purple-300">
|
||||
Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
|
@ -128,7 +129,7 @@ export const SendMessageDialog = ({
|
|||
} = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` });
|
||||
|
||||
const selectedMember = members.find((m) => m.name === member);
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false;
|
||||
const hasTeammates = members.length > 1;
|
||||
const canDelegate = hasTeammates && isLeadRecipient;
|
||||
const shouldAutoDelegate = canDelegate;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ interface TaskCommentsSectionProps {
|
|||
* Ref callback factory from useViewportCommentRead.
|
||||
* When provided, each comment element is registered for viewport-based read tracking.
|
||||
*/
|
||||
registerCommentForViewport?: (timestampMs: number) => (el: HTMLElement | null) => void;
|
||||
registerCommentForViewport?: (commentId: string) => (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export const TaskCommentsSection = ({
|
||||
|
|
@ -216,9 +216,7 @@ export const TaskCommentsSection = ({
|
|||
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
||||
<div
|
||||
ref={
|
||||
registerCommentForViewport
|
||||
? registerCommentForViewport(new Date(comment.createdAt).getTime())
|
||||
: undefined
|
||||
registerCommentForViewport ? registerCommentForViewport(comment.id) : undefined
|
||||
}
|
||||
className={[
|
||||
'group min-w-0 overflow-hidden px-4 py-2.5',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { Input } from '@renderer/components/ui/input';
|
|||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||
import { TiptapEditor } from '@renderer/components/ui/tiptap';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getLastReadTimestamp } from '@renderer/services/commentReadStorage';
|
||||
import { getLegacyCutoff, getReadCommentIds } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useViewportCommentRead } from '@renderer/hooks/useViewportCommentRead';
|
||||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from '@renderer/utils/memberHelpers';
|
||||
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
|
||||
import {
|
||||
|
|
@ -252,12 +253,14 @@ export const TaskDetailDialog = ({
|
|||
unreadSnapshotRef.current = new Set();
|
||||
return;
|
||||
}
|
||||
const lastRead = getLastReadTimestamp(teamName, currentTask.id);
|
||||
const readIds = getReadCommentIds(teamName, currentTask.id);
|
||||
const cutoff = getLegacyCutoff(teamName, currentTask.id);
|
||||
const unread = new Set<string>();
|
||||
for (const c of comments) {
|
||||
if (new Date(c.createdAt).getTime() > lastRead) {
|
||||
unread.add(c.id);
|
||||
}
|
||||
if (readIds.has(c.id)) continue;
|
||||
const ts = new Date(c.createdAt).getTime();
|
||||
if (cutoff > 0 && ts <= cutoff) continue;
|
||||
unread.add(c.id);
|
||||
}
|
||||
unreadSnapshotRef.current = unread;
|
||||
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
|
@ -507,8 +510,7 @@ export const TaskDetailDialog = ({
|
|||
.map((t) => t.id);
|
||||
const isTodo = status === 'pending' && !kanbanColumn;
|
||||
const canReassign = isTodo && onOwnerChange;
|
||||
const leadName =
|
||||
members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead';
|
||||
const leadName = members.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||
const isLeadOwnedTask =
|
||||
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
|
||||
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ interface MemberCardProps {
|
|||
spawnStatus?: MemberSpawnStatus;
|
||||
spawnError?: string;
|
||||
onOpenTask?: () => void;
|
||||
onOpenReviewTask?: () => void;
|
||||
onClick?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
onAssignTask?: () => void;
|
||||
|
|
@ -56,6 +57,7 @@ export const MemberCard = ({
|
|||
spawnStatus,
|
||||
spawnError,
|
||||
onOpenTask,
|
||||
onOpenReviewTask,
|
||||
onClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
|
|
@ -88,7 +90,6 @@ export const MemberCard = ({
|
|||
const totalTasks = pending + inProgress + completed;
|
||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||
const activityTask = currentTask ?? reviewTask ?? null;
|
||||
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : null;
|
||||
const activityTitle = currentTask
|
||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||
: reviewTask
|
||||
|
|
@ -131,21 +132,31 @@ export const MemberCard = ({
|
|||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<span className="shrink-0 font-medium text-[var(--color-text)]">{displayMemberName(member.name)}</span>
|
||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||
{displayMemberName(member.name)}
|
||||
</span>
|
||||
{member.gitBranch ? (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
<GitBranch size={10} />
|
||||
{member.gitBranch}
|
||||
</span>
|
||||
) : null}
|
||||
{activityTask && activityLabel ? (
|
||||
{currentTask ? (
|
||||
<CurrentTaskIndicator
|
||||
task={activityTask}
|
||||
task={currentTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel={activityLabel}
|
||||
activityLabel="working on"
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
) : null}
|
||||
{reviewTask ? (
|
||||
<CurrentTaskIndicator
|
||||
task={reviewTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="reviewing"
|
||||
onOpenTask={onOpenReviewTask}
|
||||
/>
|
||||
) : null}
|
||||
{!activityTask && isAwaitingReply ? (
|
||||
<>
|
||||
<Loader2
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
||||
|
||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||
|
|
@ -98,7 +99,7 @@ export const MemberDetailDialog = ({
|
|||
member={member}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
||||
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
|
||||
onUpdateRole={
|
||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||
}
|
||||
|
|
@ -186,7 +187,7 @@ export const MemberDetailDialog = ({
|
|||
<ListPlus size={14} />
|
||||
Assign Task
|
||||
</Button>
|
||||
{onRemoveMember && member.agentType !== 'team-lead' && (
|
||||
{onRemoveMember && !isLeadAgentType(member.agentType) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getMemberDotClass,
|
||||
getPresenceLabel,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
import { MemberRoleEditor } from './MemberRoleEditor';
|
||||
|
|
@ -47,7 +48,10 @@ export const MemberDetailHeader = ({
|
|||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
|
||||
const canEditRole =
|
||||
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
||||
!isLeadAgentType(member.agentType) &&
|
||||
!member.removedAt &&
|
||||
!isTeamProvisioning &&
|
||||
!!onUpdateRole;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
getMemberDotClass,
|
||||
getPresenceLabel,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
|
|
@ -62,28 +63,26 @@ export const MemberHoverCard = ({
|
|||
member,
|
||||
isTeamAlive,
|
||||
false,
|
||||
member.agentType === 'team-lead' ? leadActivity : undefined
|
||||
isLeadAgentType(member.agentType) ? leadActivity : undefined
|
||||
);
|
||||
const dotClass = getMemberDotClass(
|
||||
member,
|
||||
isTeamAlive,
|
||||
false,
|
||||
member.agentType === 'team-lead' ? leadActivity : undefined
|
||||
isLeadAgentType(member.agentType) ? leadActivity : undefined
|
||||
);
|
||||
const currentTask: TeamTaskWithKanban | null =
|
||||
member.currentTaskId && tasks
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const reviewTask: TeamTaskWithKanban | null =
|
||||
!currentTask && tasks
|
||||
? (tasks.find(
|
||||
(task) =>
|
||||
task.reviewer === member.name &&
|
||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||
) ?? null)
|
||||
: null;
|
||||
const activityTask = currentTask ?? reviewTask;
|
||||
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : 'working on';
|
||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||
? (tasks.find(
|
||||
(task) =>
|
||||
task.reviewer === member.name &&
|
||||
task.id !== member.currentTaskId &&
|
||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={300} closeDelay={200}>
|
||||
|
|
@ -131,14 +130,27 @@ export const MemberHoverCard = ({
|
|||
</div>
|
||||
|
||||
{/* Current task */}
|
||||
{activityTask && (
|
||||
{currentTask && (
|
||||
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
|
||||
<CurrentTaskIndicator
|
||||
task={activityTask}
|
||||
task={currentTask}
|
||||
borderColor={colors.border}
|
||||
maxSubjectLength={28}
|
||||
activityLabel={activityLabel}
|
||||
onOpenTask={onOpenTask ? () => onOpenTask(activityTask) : undefined}
|
||||
activityLabel="working on"
|
||||
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review task */}
|
||||
{reviewTask && (
|
||||
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
|
||||
<CurrentTaskIndicator
|
||||
task={reviewTask}
|
||||
borderColor={colors.border}
|
||||
maxSubjectLength={28}
|
||||
activityLabel="reviewing"
|
||||
onOpenTask={onOpenTask ? () => onOpenTask(reviewTask) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||
|
||||
import { MemberCard } from './MemberCard';
|
||||
|
||||
|
|
@ -69,8 +70,8 @@ export const MemberList = ({
|
|||
const activeMembers = members
|
||||
.filter((m) => !m.removedAt)
|
||||
.sort((a, b) => {
|
||||
if (a.agentType === 'team-lead') return -1;
|
||||
if (b.agentType === 'team-lead') return 1;
|
||||
if (isLeadAgentType(a.agentType)) return -1;
|
||||
if (isLeadAgentType(b.agentType)) return 1;
|
||||
return 0;
|
||||
});
|
||||
const removedMembers = members.filter((m) => m.removedAt);
|
||||
|
|
@ -87,14 +88,14 @@ export const MemberList = ({
|
|||
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
||||
const currentTask =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const reviewTask =
|
||||
!currentTask && taskMap
|
||||
? (Array.from(taskMap.values()).find(
|
||||
(task) =>
|
||||
task.reviewer === member.name &&
|
||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||
) ?? null)
|
||||
: null;
|
||||
const reviewTask = taskMap
|
||||
? (Array.from(taskMap.values()).find(
|
||||
(task) =>
|
||||
task.reviewer === member.name &&
|
||||
task.id !== member.currentTaskId &&
|
||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||
) ?? null)
|
||||
: null;
|
||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
return (
|
||||
|
|
@ -105,18 +106,15 @@ export const MemberList = ({
|
|||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
||||
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
|
||||
currentTask={isRemoved ? null : currentTask}
|
||||
reviewTask={isRemoved ? null : reviewTask}
|
||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||
isRemoved={isRemoved}
|
||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||
onOpenTask={
|
||||
!isRemoved && (currentTask ?? reviewTask)
|
||||
? () => onOpenTask?.((currentTask ?? reviewTask)!)
|
||||
: undefined
|
||||
}
|
||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
onAssignTask={() => onAssignTask?.(member)}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -86,7 +87,7 @@ export const MessageComposer = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [externalTextareaRef]);
|
||||
const [recipient, setRecipient] = useState<string>(() => {
|
||||
const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
|
||||
const lead = members.find((m) => isLeadMember(m));
|
||||
return lead?.name ?? members[0]?.name ?? '';
|
||||
});
|
||||
const [recipientOpen, setRecipientOpen] = useState(false);
|
||||
|
|
@ -166,7 +167,7 @@ export const MessageComposer = ({
|
|||
if (recipient && members.some((m) => m.name === recipient)) {
|
||||
return;
|
||||
}
|
||||
const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
|
||||
const lead = members.find((m) => isLeadMember(m));
|
||||
const next = lead?.name ?? members[0]?.name ?? '';
|
||||
if (next && next !== recipient) {
|
||||
queueMicrotask(() => setRecipient(next));
|
||||
|
|
@ -203,7 +204,7 @@ export const MessageComposer = ({
|
|||
|
||||
const selectedMember = members.find((m) => m.name === recipient);
|
||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false;
|
||||
const hasTeammates = members.length > 1;
|
||||
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
|
||||
const shouldAutoDelegate = isLeadRecipient && canDelegate;
|
||||
|
|
@ -681,8 +682,8 @@ export const MessageComposer = ({
|
|||
);
|
||||
}
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
|
||||
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
|
||||
const aIsLead = isLeadMember(a) ? 1 : 0;
|
||||
const bIsLead = isLeadMember(b) ? 1 : 0;
|
||||
return bIsLead - aIsLead;
|
||||
});
|
||||
return sorted.map((m) => {
|
||||
|
|
@ -787,8 +788,8 @@ export const MessageComposer = ({
|
|||
);
|
||||
}
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
|
||||
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
|
||||
const aIsLead = isLeadMember(a) ? 1 : 0;
|
||||
const bIsLead = isLeadMember(b) ? 1 : 0;
|
||||
return bIsLead - aIsLead;
|
||||
});
|
||||
return sorted.map((m) => {
|
||||
|
|
|
|||
|
|
@ -17,4 +17,4 @@ export const CUSTOM_ROLE = '__custom__';
|
|||
export const NO_ROLE = '__none__';
|
||||
|
||||
/** Roles that cannot be assigned manually (reserved for system use). */
|
||||
export const FORBIDDEN_ROLES = new Set(['lead', 'team-lead']);
|
||||
export const FORBIDDEN_ROLES = new Set(['lead', 'team-lead', 'orchestrator']);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { markCommentsRead } from '@renderer/services/commentReadStorage';
|
||||
|
||||
import { useViewportObserver } from './useViewportObserver';
|
||||
|
||||
|
|
@ -20,31 +20,31 @@ interface UseViewportCommentReadOptions {
|
|||
/**
|
||||
* Marks task comments as read based on viewport visibility.
|
||||
*
|
||||
* Instead of marking all comments read on mount, this hook uses
|
||||
* IntersectionObserver (via useViewportObserver) to detect which
|
||||
* comment elements are visible in the scroll container and updates
|
||||
* the per-task read timestamp to the newest visible comment.
|
||||
* Uses IntersectionObserver (via useViewportObserver) to detect which
|
||||
* comment elements are visible in the scroll container and records
|
||||
* their individual IDs as read via per-comment ID tracking.
|
||||
*
|
||||
* Each comment element should be registered via the returned
|
||||
* `registerComment(commentTimestampMs)` ref callback.
|
||||
* `registerComment(commentId)` ref callback.
|
||||
*
|
||||
* Compatible with the existing per-task timestamp storage format
|
||||
* in commentReadStorage — no storage schema changes needed.
|
||||
* Only comments that have actually been scrolled into view are marked
|
||||
* as read — fixes the bug where DESC-sorted comments caused all
|
||||
* comments to be marked read when the newest was visible at the top.
|
||||
*/
|
||||
export function useViewportCommentRead({
|
||||
teamName,
|
||||
taskId,
|
||||
scrollContainerRef,
|
||||
}: UseViewportCommentReadOptions): {
|
||||
/** Ref callback factory. Call with the comment's createdAt timestamp (ms). */
|
||||
registerComment: (timestampMs: number) => (el: HTMLElement | null) => void;
|
||||
/** Ref callback factory. Call with the comment's unique ID. */
|
||||
registerComment: (commentId: string) => (el: HTMLElement | null) => void;
|
||||
/**
|
||||
* Flush the highest observed timestamp now. Call on dialog close
|
||||
* Flush all observed comment IDs now. Call on dialog close
|
||||
* as a safety fallback (e.g. if IO did not fire for portal reasons).
|
||||
*/
|
||||
flush: () => void;
|
||||
} {
|
||||
const highestSeenRef = useRef(0);
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const teamNameRef = useRef(teamName);
|
||||
const taskIdRef = useRef(taskId);
|
||||
teamNameRef.current = teamName;
|
||||
|
|
@ -52,23 +52,31 @@ export function useViewportCommentRead({
|
|||
|
||||
// Reset tracked state when team/task changes
|
||||
useEffect(() => {
|
||||
highestSeenRef.current = 0;
|
||||
seenIdsRef.current = new Set();
|
||||
}, [teamName, taskId]);
|
||||
|
||||
const handleVisibleChange = useCallback((visibleValues: string[]) => {
|
||||
let maxTs = 0;
|
||||
for (const v of visibleValues) {
|
||||
const ts = Number(v);
|
||||
if (Number.isFinite(ts) && ts > maxTs) {
|
||||
maxTs = ts;
|
||||
}
|
||||
}
|
||||
if (maxTs > 0 && maxTs > highestSeenRef.current) {
|
||||
highestSeenRef.current = maxTs;
|
||||
markAsRead(teamNameRef.current, taskIdRef.current, maxTs);
|
||||
const persistSeen = useCallback(() => {
|
||||
if (seenIdsRef.current.size > 0) {
|
||||
markCommentsRead(teamNameRef.current, taskIdRef.current, Array.from(seenIdsRef.current));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVisibleChange = useCallback(
|
||||
(visibleValues: string[]) => {
|
||||
let changed = false;
|
||||
for (const id of visibleValues) {
|
||||
if (id && !seenIdsRef.current.has(id)) {
|
||||
seenIdsRef.current.add(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
persistSeen();
|
||||
}
|
||||
},
|
||||
[persistSeen]
|
||||
);
|
||||
|
||||
const { registerElement } = useViewportObserver({
|
||||
rootRef: scrollContainerRef,
|
||||
threshold: 0.1,
|
||||
|
|
@ -76,15 +84,13 @@ export function useViewportCommentRead({
|
|||
});
|
||||
|
||||
const registerComment = useCallback(
|
||||
(timestampMs: number) => registerElement(String(timestampMs)),
|
||||
(commentId: string) => registerElement(commentId),
|
||||
[registerElement]
|
||||
);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (highestSeenRef.current > 0) {
|
||||
markAsRead(teamNameRef.current, taskIdRef.current, highestSeenRef.current);
|
||||
}
|
||||
}, []);
|
||||
persistSeen();
|
||||
}, [persistSeen]);
|
||||
|
||||
return { registerComment, flush };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
import { get, set } from 'idb-keyval';
|
||||
|
||||
const IDB_KEY = 'comment-read-state';
|
||||
const LS_KEY = 'comment-read-state';
|
||||
const IDB_KEY = 'comment-read-state-v2';
|
||||
const LS_KEY = 'comment-read-state-v2';
|
||||
const LEGACY_IDB_KEY = 'comment-read-state';
|
||||
const LEGACY_LS_KEY = 'comment-read-state';
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
type ReadState = Record<string, number>; // key = "teamName/taskId", value = timestamp
|
||||
/**
|
||||
* Per-task read state: tracks individual comment IDs that have been seen.
|
||||
* `lastUpdated` is used for stale cleanup (prune entries older than 30 days).
|
||||
*/
|
||||
interface TaskReadEntry {
|
||||
readIds: string[];
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
// --- localStorage fallback ---
|
||||
type ReadState = Record<string, TaskReadEntry>; // key = "teamName/taskId"
|
||||
|
||||
// Legacy format for migration (v1 stored a single timestamp per task)
|
||||
type LegacyReadState = Record<string, number>;
|
||||
|
||||
// --- localStorage helpers ---
|
||||
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;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
return parsed as ReadState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -29,10 +42,57 @@ function lsSave(state: ReadState): void {
|
|||
}
|
||||
}
|
||||
|
||||
function lsLoadLegacy(): LegacyReadState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_LS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
// Verify it's the old format (values are numbers, not objects)
|
||||
const entries = Object.entries(parsed as Record<string, unknown>);
|
||||
if (entries.length > 0 && typeof entries[0][1] === 'number') {
|
||||
return parsed as LegacyReadState;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy per-task timestamp to per-comment ID format.
|
||||
* Since we don't have comment IDs from the old format, we treat all
|
||||
* comments with timestamps <= the old lastRead as "read" by storing
|
||||
* a sentinel marker. The actual per-comment tracking starts fresh.
|
||||
*/
|
||||
function migrateLegacy(legacy: LegacyReadState): ReadState {
|
||||
const migrated: ReadState = {};
|
||||
for (const [key, timestamp] of Object.entries(legacy)) {
|
||||
if (typeof timestamp === 'number' && timestamp > 0) {
|
||||
// Store legacy timestamp as a sentinel — getUnreadCount will use it
|
||||
// for comments older than migration, and per-ID for newer ones.
|
||||
migrated[key] = {
|
||||
readIds: [],
|
||||
lastUpdated: timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
// Synchronous init from localStorage — guarantees first render sees read state
|
||||
const lsInitial = lsLoad();
|
||||
let cache: ReadState = lsInitial ?? {};
|
||||
let loaded = lsInitial !== null && Object.keys(lsInitial).length > 0;
|
||||
let cache: ReadState = {};
|
||||
const v2Data = lsLoad();
|
||||
if (v2Data && Object.keys(v2Data).length > 0) {
|
||||
cache = v2Data;
|
||||
} else {
|
||||
const legacyData = lsLoadLegacy();
|
||||
if (legacyData && Object.keys(legacyData).length > 0) {
|
||||
cache = migrateLegacy(legacyData);
|
||||
}
|
||||
}
|
||||
|
||||
let loaded = Object.keys(cache).length > 0;
|
||||
let idbAvailable = true; // flips to false on first IndexedDB failure
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
|
@ -51,31 +111,109 @@ export function getSnapshot(): ReadState {
|
|||
}
|
||||
|
||||
// --- Mutations ---
|
||||
export function markAsRead(teamName: string, taskId: string, latestTimestamp: number): void {
|
||||
|
||||
/**
|
||||
* Mark specific comment IDs as read for a given team/task.
|
||||
*/
|
||||
export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void {
|
||||
if (commentIds.length === 0) return;
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const prev = cache[key] ?? 0;
|
||||
if (latestTimestamp <= prev) return;
|
||||
cache = { ...cache, [key]: latestTimestamp };
|
||||
const prev = cache[key];
|
||||
const prevSet = new Set(prev?.readIds ?? []);
|
||||
let changed = false;
|
||||
for (const id of commentIds) {
|
||||
if (!prevSet.has(id)) {
|
||||
prevSet.add(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
cache = {
|
||||
...cache,
|
||||
[key]: {
|
||||
readIds: Array.from(prevSet),
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
};
|
||||
notify();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use markCommentsRead() instead. Kept for backward compatibility
|
||||
* with code that hasn't migrated yet (e.g. flush fallback).
|
||||
*/
|
||||
export function markAsRead(teamName: string, taskId: string, latestTimestamp: number): void {
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const prev = cache[key];
|
||||
// Update lastUpdated to at least this timestamp (for legacy migration support)
|
||||
const prevLastUpdated = prev?.lastUpdated ?? 0;
|
||||
if (latestTimestamp <= prevLastUpdated && prev) return;
|
||||
cache = {
|
||||
...cache,
|
||||
[key]: {
|
||||
readIds: prev?.readIds ?? [],
|
||||
lastUpdated: Math.max(prevLastUpdated, latestTimestamp),
|
||||
},
|
||||
};
|
||||
notify();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unread comments for a task.
|
||||
* A comment is unread if:
|
||||
* 1. Its ID is NOT in the readIds set, AND
|
||||
* 2. Its timestamp is AFTER the lastUpdated migration marker (for legacy data)
|
||||
*/
|
||||
export function getUnreadCount(
|
||||
readState: ReadState,
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
comments: { createdAt: string }[]
|
||||
comments: { id?: string; createdAt: string }[]
|
||||
): number {
|
||||
if (!comments || comments.length === 0) return 0;
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const lastRead = readState[key] ?? 0;
|
||||
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
|
||||
const entry = readState[key];
|
||||
if (!entry) return comments.length;
|
||||
|
||||
const readSet = new Set(entry.readIds);
|
||||
const legacyCutoff = entry.lastUpdated;
|
||||
|
||||
let count = 0;
|
||||
for (const c of comments) {
|
||||
// If comment has an ID and it's in the read set → read
|
||||
if (c.id && readSet.has(c.id)) continue;
|
||||
// If comment was created before/at the legacy cutoff → read (migrated data)
|
||||
const ts = new Date(c.createdAt).getTime();
|
||||
if (legacyCutoff > 0 && ts <= legacyCutoff) continue;
|
||||
// Otherwise → unread
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Return the last-read timestamp for a team/task pair (0 if never read). */
|
||||
/**
|
||||
* Get the set of read comment IDs for a team/task pair.
|
||||
*/
|
||||
export function getReadCommentIds(teamName: string, taskId: string): Set<string> {
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const entry = cache[key];
|
||||
return new Set(entry?.readIds ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the legacy migration cutoff timestamp for a team/task pair (0 if none).
|
||||
*/
|
||||
export function getLegacyCutoff(teamName: string, taskId: string): number {
|
||||
const key = `${teamName}/${taskId}`;
|
||||
return cache[key]?.lastUpdated ?? 0;
|
||||
}
|
||||
|
||||
/** @deprecated Use getReadCommentIds() + getLegacyCutoff() instead. */
|
||||
export function getLastReadTimestamp(teamName: string, taskId: string): number {
|
||||
const key = `${teamName}/${taskId}`;
|
||||
return cache[key] ?? 0;
|
||||
return cache[key]?.lastUpdated ?? 0;
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
|
|
@ -98,17 +236,48 @@ function scheduleSave(): void {
|
|||
async function load(): Promise<void> {
|
||||
if (loaded) return;
|
||||
|
||||
// IDB may have fresher data — merge with max timestamp per key
|
||||
if (hasIndexedDB() && idbAvailable) {
|
||||
try {
|
||||
// Try v2 format first
|
||||
const stored = await get<ReadState>(IDB_KEY);
|
||||
if (stored && typeof stored === 'object') {
|
||||
const merged = { ...cache };
|
||||
for (const [k, v] of Object.entries(stored)) {
|
||||
merged[k] = Math.max(merged[k] ?? 0, v);
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
const entry = v as TaskReadEntry;
|
||||
const prev = merged[k];
|
||||
if (!prev) {
|
||||
merged[k] = entry;
|
||||
} else {
|
||||
// Merge: union of readIds, max lastUpdated
|
||||
const mergedIds = new Set([...prev.readIds, ...entry.readIds]);
|
||||
merged[k] = {
|
||||
readIds: Array.from(mergedIds),
|
||||
lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated),
|
||||
};
|
||||
}
|
||||
}
|
||||
cache = merged;
|
||||
notify();
|
||||
} else {
|
||||
// Try legacy IDB format
|
||||
const legacy = await get<LegacyReadState>(LEGACY_IDB_KEY);
|
||||
if (legacy && typeof legacy === 'object') {
|
||||
const migrated = migrateLegacy(legacy);
|
||||
const merged = { ...cache };
|
||||
for (const [k, v] of Object.entries(migrated)) {
|
||||
if (!merged[k]) {
|
||||
merged[k] = v;
|
||||
} else {
|
||||
merged[k] = {
|
||||
readIds: [...new Set([...merged[k].readIds, ...v.readIds])],
|
||||
lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated),
|
||||
};
|
||||
}
|
||||
}
|
||||
cache = merged;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
idbAvailable = false;
|
||||
|
|
@ -134,31 +303,27 @@ async function save(): Promise<void> {
|
|||
|
||||
export async function cleanupStale(): Promise<void> {
|
||||
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(state)) {
|
||||
if (now - v < STALE_THRESHOLD_MS) {
|
||||
result[k] = v;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
let changed = false;
|
||||
const result: ReadState = {};
|
||||
for (const [k, v] of Object.entries(cache)) {
|
||||
if (now - v.lastUpdated < STALE_THRESHOLD_MS) {
|
||||
result[k] = v;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
return { cleaned: result, changed };
|
||||
};
|
||||
}
|
||||
|
||||
const { cleaned, changed } = clean(cache);
|
||||
if (!changed) return;
|
||||
|
||||
// Update in-memory cache
|
||||
cache = cleaned;
|
||||
cache = result;
|
||||
notify();
|
||||
|
||||
// Persist to both storages
|
||||
lsSave(cleaned);
|
||||
lsSave(result);
|
||||
if (idbAvailable && hasIndexedDB()) {
|
||||
try {
|
||||
await set(IDB_KEY, cleaned);
|
||||
await set(IDB_KEY, result);
|
||||
} catch {
|
||||
idbAvailable = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
|
|||
selectProject: (id: string) => {
|
||||
set({
|
||||
selectedProjectId: id,
|
||||
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
|
||||
...getSessionResetState(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
|||
selectedWorktreeId: worktreeToSelect.id,
|
||||
selectedProjectId: worktreeToSelect.id,
|
||||
activeProjectId: worktreeToSelect.id,
|
||||
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
|
||||
...getSessionResetState(),
|
||||
});
|
||||
// Fetch sessions for this worktree
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ export function getWorktreeNavigationState(repoId: string, worktreeId: string):
|
|||
selectedWorktreeId: worktreeId,
|
||||
selectedProjectId: worktreeId,
|
||||
activeProjectId: worktreeId,
|
||||
sidebarCollapsed: false,
|
||||
...getSessionResetState(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
MEMBER_COLOR_PALETTE,
|
||||
normalizeMemberColorName,
|
||||
} from '@shared/constants/memberColors';
|
||||
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
LeadActivityState,
|
||||
|
|
@ -42,7 +43,7 @@ export function getMemberDotClass(
|
|||
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') {
|
||||
if (leadActivity && isLeadAgentType(member.agentType)) {
|
||||
return leadActivity === 'active'
|
||||
? `${STATUS_DOT_COLORS.active} animate-pulse`
|
||||
: STATUS_DOT_COLORS.active;
|
||||
|
|
@ -62,7 +63,7 @@ export function getPresenceLabel(
|
|||
if (member.status === 'terminated') return 'terminated';
|
||||
if (isTeamProvisioning) return 'connecting';
|
||||
if (isTeamAlive === false) return 'offline';
|
||||
if (leadActivity && member.agentType === 'team-lead') {
|
||||
if (leadActivity && isLeadAgentType(member.agentType)) {
|
||||
if (leadActivity === 'active') {
|
||||
return leadContextPercent != null && leadContextPercent > 0
|
||||
? `processing (${Math.round(leadContextPercent)}%)`
|
||||
|
|
|
|||
34
src/shared/utils/leadDetection.ts
Normal file
34
src/shared/utils/leadDetection.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Lead agent type detection.
|
||||
*
|
||||
* CLI Claude Code assigns inconsistent agentType values to the lead member
|
||||
* across different versions/runs: "team-lead", "lead", "orchestrator",
|
||||
* or even "general-purpose". This module centralizes lead detection
|
||||
* so the rest of the codebase does not need to hard-code any single value.
|
||||
*/
|
||||
|
||||
const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']);
|
||||
|
||||
/**
|
||||
* Returns true if the given agentType string identifies a team lead.
|
||||
* Handles all known CLI variants: "team-lead", "lead", "orchestrator".
|
||||
*
|
||||
* Does NOT match "general-purpose" — that value is ambiguous and used
|
||||
* for regular teammates too. Lead detection for "general-purpose" agents
|
||||
* must rely on name-based checks (see {@link isLeadMember}).
|
||||
*/
|
||||
export function isLeadAgentType(agentType: string | undefined | null): boolean {
|
||||
if (!agentType) return false;
|
||||
return LEAD_AGENT_TYPES.has(agentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the member is a team lead, checking both agentType
|
||||
* and the conventional "team-lead" name as a fallback.
|
||||
*/
|
||||
export function isLeadMember(member: { agentType?: unknown; name?: unknown }): boolean {
|
||||
const agentType = typeof member.agentType === 'string' ? member.agentType : null;
|
||||
if (isLeadAgentType(agentType)) return true;
|
||||
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
||||
return name === 'team-lead';
|
||||
}
|
||||
Loading…
Reference in a new issue