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 = {}) {
|
function buildAssignmentMessage(context, task, options = {}) {
|
||||||
const description =
|
const description =
|
||||||
typeof options.description === 'string' && options.description.trim()
|
typeof options.description === 'string' && options.description.trim()
|
||||||
|
|
@ -74,7 +81,7 @@ function buildCommentNotificationMessage(context, task, comment) {
|
||||||
`**Comment on task ${taskLabel}**`,
|
`**Comment on task ${taskLabel}**`,
|
||||||
`> ${task.subject}`,
|
`> ${task.subject}`,
|
||||||
``,
|
``,
|
||||||
comment.text,
|
quoteMarkdown(comment.text),
|
||||||
``,
|
``,
|
||||||
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
||||||
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
|
{ 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',
|
timestamp: '2026-02-23T11:00:00.000Z',
|
||||||
read: false,
|
read: false,
|
||||||
text:
|
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>',
|
'<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 { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
||||||
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -194,7 +195,7 @@ export class CrossTeamService {
|
||||||
}
|
}
|
||||||
if (!config || config.deletedAt) continue;
|
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({
|
targets.push({
|
||||||
teamName: entry,
|
teamName: entry,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import {
|
import {
|
||||||
createCliAutoSuffixNameGuard,
|
createCliAutoSuffixNameGuard,
|
||||||
|
|
@ -207,7 +208,7 @@ export class TeamConfigReader {
|
||||||
const name = m.name?.trim();
|
const name = m.name?.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
// 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();
|
const key = name.toLowerCase();
|
||||||
// If meta marks this name removed, do not surface it in summaries
|
// If meta marks this name removed, do not surface it in summaries
|
||||||
if (removedKeys.has(key)) return;
|
if (removedKeys.has(key)) return;
|
||||||
|
|
@ -227,7 +228,7 @@ export class TeamConfigReader {
|
||||||
const name = member.name?.trim();
|
const name = member.name?.trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
// 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();
|
const key = name.toLowerCase();
|
||||||
if (member.removedAt) {
|
if (member.removedAt) {
|
||||||
removedKeys.add(key);
|
removedKeys.add(key);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
stripAgentBlocks,
|
stripAgentBlocks,
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -585,7 +586,7 @@ export class TeamDataService {
|
||||||
config: TeamConfig
|
config: TeamConfig
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
|
// 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;
|
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
||||||
if (!leadCwd) return;
|
if (!leadCwd) return;
|
||||||
|
|
||||||
|
|
@ -737,7 +738,7 @@ export class TeamDataService {
|
||||||
): Promise<{ oldRole: string | undefined; changed: boolean }> {
|
): Promise<{ oldRole: string | undefined; changed: boolean }> {
|
||||||
const { members, member } = await this.ensureMemberInMeta(teamName, memberName);
|
const { members, member } = await this.ensureMemberInMeta(teamName, memberName);
|
||||||
if (member.removedAt) throw new Error(`Member "${memberName}" is removed`);
|
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 oldRole = member.role;
|
||||||
const normalized = typeof newRole === 'string' && newRole.trim() ? newRole.trim() : undefined;
|
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 }[] }
|
request: { members: { name: string; role?: string; workflow?: string }[] }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||||
const isTeamLead = (m: TeamMember): boolean =>
|
const existingLead = existing.find(isLeadMember) ?? null;
|
||||||
m.agentType === 'team-lead' || m.name.trim().toLowerCase() === 'team-lead';
|
|
||||||
const existingLead = existing.find(isTeamLead) ?? null;
|
|
||||||
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
const nextByName = new Set<string>();
|
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.
|
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||||
const nextRemoved: TeamMember[] = [];
|
const nextRemoved: TeamMember[] = [];
|
||||||
for (const prev of existing) {
|
for (const prev of existing) {
|
||||||
if (isTeamLead(prev)) continue;
|
if (isLeadMember(prev)) continue;
|
||||||
const prevName = prev.name.trim();
|
const prevName = prev.name.trim();
|
||||||
if (!prevName) continue;
|
if (!prevName) continue;
|
||||||
const key = prevName.toLowerCase();
|
const key = prevName.toLowerCase();
|
||||||
|
|
@ -815,7 +814,7 @@ export class TeamDataService {
|
||||||
if (member.removedAt) {
|
if (member.removedAt) {
|
||||||
throw new Error(`Member "${memberName}" is already removed`);
|
throw new Error(`Member "${memberName}" is already removed`);
|
||||||
}
|
}
|
||||||
if (member.agentType === 'team-lead') {
|
if (isLeadAgentType(member.agentType)) {
|
||||||
throw new Error('Cannot remove team lead');
|
throw new Error('Cannot remove team lead');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1544,16 +1543,14 @@ export class TeamDataService {
|
||||||
|
|
||||||
// Check config.json members first (Claude Code-created teams)
|
// Check config.json members first (Claude Code-created teams)
|
||||||
if (config?.members?.length) {
|
if (config?.members?.length) {
|
||||||
const lead = config.members.find(
|
const lead = config.members.find((m) => isLeadMember(m));
|
||||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
|
||||||
);
|
|
||||||
if (lead?.name) return lead.name;
|
if (lead?.name) return lead.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check members.meta.json (UI-created teams)
|
// Fallback: check members.meta.json (UI-created teams)
|
||||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
if (metaMembers.length > 0) {
|
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;
|
if (lead?.name) return lead.name;
|
||||||
return metaMembers[0]?.name ?? null;
|
return metaMembers[0]?.name ?? null;
|
||||||
}
|
}
|
||||||
|
|
@ -1844,7 +1841,7 @@ export class TeamDataService {
|
||||||
return [];
|
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);
|
const sessionIds = this.getRecentLeadSessionIds(config);
|
||||||
if (sessionIds.length === 0) {
|
if (sessionIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
|
|
@ -109,7 +110,7 @@ export class TeamMemberLogsFinder {
|
||||||
const results: MemberLogSummary[] = [];
|
const results: MemberLogSummary[] = [];
|
||||||
|
|
||||||
const leadMemberName =
|
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) {
|
if (isLeadMember && config.leadSessionId) {
|
||||||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||||
const leadSummary = await this.parseLeadSessionSummary(
|
const leadSummary = await this.parseLeadSessionSummary(
|
||||||
|
|
@ -200,7 +201,7 @@ export class TeamMemberLogsFinder {
|
||||||
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
||||||
const results: MemberLogSummary[] = [];
|
const results: MemberLogSummary[] = [];
|
||||||
const leadMemberName =
|
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) {
|
if (config.leadSessionId) {
|
||||||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||||
|
|
@ -422,7 +423,7 @@ export class TeamMemberLogsFinder {
|
||||||
const refs: { filePath: string; memberName: string; sortTime: number }[] = [];
|
const refs: { filePath: string; memberName: string; sortTime: number }[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const leadMemberName =
|
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 pushRef = (filePath: string, memberName: string, sortTime = 0): void => {
|
||||||
const key = `${memberName.toLowerCase()}:${filePath}`;
|
const key = `${memberName.toLowerCase()}:${filePath}`;
|
||||||
|
|
@ -817,7 +818,7 @@ export class TeamMemberLogsFinder {
|
||||||
if (!discovery) return null;
|
if (!discovery) return null;
|
||||||
const { config } = discovery;
|
const { config } = discovery;
|
||||||
const leadMemberName =
|
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();
|
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
|
||||||
return { ...discovery, isLeadMember };
|
return { ...discovery, isLeadMember };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||||
|
|
@ -3582,7 +3583,7 @@ export class TeamProvisioningService {
|
||||||
if (!config) return 0;
|
if (!config) return 0;
|
||||||
|
|
||||||
const leadName =
|
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']>> = [];
|
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -4855,11 +4856,11 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(run.teamName);
|
const config = await this.configReader.getConfig(run.teamName);
|
||||||
if (config?.members) {
|
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';
|
leadName = configLead?.name?.trim() || 'team-lead';
|
||||||
// Convert config members (excluding lead) to TeamCreateRequest member format.
|
// Convert config members (excluding lead) to TeamCreateRequest member format.
|
||||||
currentMembers = config.members
|
currentMembers = config.members
|
||||||
.filter((m) => m?.agentType !== 'team-lead' && m?.name)
|
.filter((m) => !isLeadAgentType(m?.agentType) && m?.name)
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: m.role ?? undefined,
|
role: m.role ?? undefined,
|
||||||
|
|
@ -5310,7 +5311,8 @@ export class TeamProvisioningService {
|
||||||
members?: { name?: string; agentType?: string }[];
|
members?: { name?: string; agentType?: string }[];
|
||||||
};
|
};
|
||||||
const suffixed = (config.members ?? []).filter(
|
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) {
|
if (suffixed.length > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -6155,7 +6157,7 @@ export class TeamProvisioningService {
|
||||||
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||||
const agentType = typeof m.agentType === 'string' ? m.agentType : '';
|
const agentType = typeof m.agentType === 'string' ? m.agentType : '';
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
if (agentType === 'team-lead' || name === 'team-lead' || name === 'user') {
|
if (isLeadMember(m) || name === 'user') {
|
||||||
nextMembers.push(m);
|
nextMembers.push(m);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -6196,7 +6198,7 @@ export class TeamProvisioningService {
|
||||||
const name = m.name?.trim() ?? '';
|
const name = m.name?.trim() ?? '';
|
||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
const lower = name.toLowerCase();
|
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)) {
|
if (!m.removedAt && !keepName(name)) {
|
||||||
removedFromMeta.push(name);
|
removedFromMeta.push(name);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -6299,9 +6301,8 @@ export class TeamProvisioningService {
|
||||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const lower = name.toLowerCase();
|
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;
|
const leadAgentId = config.leadAgentId;
|
||||||
if (
|
if (
|
||||||
|
|
@ -6343,7 +6344,7 @@ export class TeamProvisioningService {
|
||||||
// Keep only the lead entry.
|
// Keep only the lead entry.
|
||||||
const leadMembers = members.filter((member) => {
|
const leadMembers = members.filter((member) => {
|
||||||
const agentType = member.agentType;
|
const agentType = member.agentType;
|
||||||
if (typeof agentType === 'string' && agentType === 'team-lead') {
|
if (typeof agentType === 'string' && isLeadAgentType(agentType)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const leadAgentId = config.leadAgentId;
|
const leadAgentId = config.leadAgentId;
|
||||||
|
|
@ -6381,7 +6382,7 @@ export class TeamProvisioningService {
|
||||||
if (
|
if (
|
||||||
name &&
|
name &&
|
||||||
agentType &&
|
agentType &&
|
||||||
agentType !== 'team-lead' &&
|
!isLeadAgentType(agentType) &&
|
||||||
name !== 'team-lead' &&
|
name !== 'team-lead' &&
|
||||||
name !== 'user'
|
name !== 'user'
|
||||||
) {
|
) {
|
||||||
|
|
@ -6652,7 +6653,7 @@ export class TeamProvisioningService {
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
const rawName = member.name?.trim() ?? '';
|
const rawName = member.name?.trim() ?? '';
|
||||||
const lower = rawName.toLowerCase();
|
const lower = rawName.toLowerCase();
|
||||||
if (member.agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') {
|
if (isLeadMember(member) || lower === 'user') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const name = rawName;
|
const name = rawName;
|
||||||
|
|
@ -6771,13 +6772,7 @@ export class TeamProvisioningService {
|
||||||
for (const member of parsed.members) {
|
for (const member of parsed.members) {
|
||||||
const rawName = typeof member?.name === 'string' ? member.name.trim() : '';
|
const rawName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||||
const lower = rawName.toLowerCase();
|
const lower = rawName.toLowerCase();
|
||||||
if (
|
if (!member || isLeadMember(member) || lower === 'user') continue;
|
||||||
!member ||
|
|
||||||
member.agentType === 'team-lead' ||
|
|
||||||
lower === 'team-lead' ||
|
|
||||||
lower === 'user'
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
const name = rawName;
|
const name = rawName;
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
byName.set(name, { name });
|
byName.set(name, { name });
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { parentPort } from 'node:worker_threads';
|
import { parentPort } from 'node:worker_threads';
|
||||||
|
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
interface ListTeamsPayload {
|
interface ListTeamsPayload {
|
||||||
teamsDir: string;
|
teamsDir: string;
|
||||||
largeConfigBytes: number;
|
largeConfigBytes: number;
|
||||||
|
|
@ -263,7 +265,7 @@ function mergeMember(
|
||||||
): void {
|
): void {
|
||||||
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return;
|
if (name === 'user' || isLeadMember(m)) return;
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
if (removedKeys.has(key)) return;
|
if (removedKeys.has(key)) return;
|
||||||
const existing = memberMap.get(key);
|
const existing = memberMap.get(key);
|
||||||
|
|
@ -433,7 +435,7 @@ async function listTeams(
|
||||||
if (!isRawMember(member)) continue;
|
if (!isRawMember(member)) continue;
|
||||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
if (name === 'team-lead' || member.agentType === 'team-lead') continue;
|
if (isLeadMember(member)) continue;
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
if (member.removedAt) {
|
if (member.removedAt) {
|
||||||
removedKeys.add(key);
|
removedKeys.add(key);
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function getTaskUnreadCount(
|
||||||
readState: ReturnType<typeof getSnapshot>,
|
readState: ReturnType<typeof getSnapshot>,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
comments: { createdAt: string }[] | undefined
|
comments: { id?: string; createdAt: string }[] | undefined
|
||||||
): number {
|
): number {
|
||||||
return getUnreadCount(readState, teamName, taskId, comments ?? []);
|
return getUnreadCount(readState, teamName, taskId, comments ?? []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||||
|
|
@ -693,7 +694,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
}, [filteredTasks, kanbanSearch]);
|
}, [filteredTasks, kanbanSearch]);
|
||||||
|
|
||||||
const activeTeammateCount = useMemo(
|
const activeTeammateCount = useMemo(
|
||||||
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
() => activeMembers.filter((m) => !isLeadMember(m)).length,
|
||||||
[activeMembers]
|
[activeMembers]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1744,7 +1745,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
currentName={data.config.name}
|
currentName={data.config.name}
|
||||||
currentDescription={data.config.description ?? ''}
|
currentDescription={data.config.description ?? ''}
|
||||||
currentColor={data.config.color ?? ''}
|
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}
|
projectPath={data.config.projectPath}
|
||||||
onClose={() => setEditDialogOpen(false)}
|
onClose={() => setEditDialogOpen(false)}
|
||||||
onSaved={() => void selectTeam(teamName)}
|
onSaved={() => void selectTeam(teamName)}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
|
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -38,12 +39,19 @@ import {
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||||
|
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||||
import { TeamEmptyState } from './TeamEmptyState';
|
import { TeamEmptyState } from './TeamEmptyState';
|
||||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||||
|
|
||||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
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 {
|
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
||||||
const base = sourceName.replace(/-\d+$/, '');
|
const base = sourceName.replace(/-\d+$/, '');
|
||||||
|
|
@ -467,7 +475,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
const existingNames = teams.map((t) => t.teamName);
|
const existingNames = teams.map((t) => t.teamName);
|
||||||
const uniqueName = generateUniqueName(teamName, existingNames);
|
const uniqueName = generateUniqueName(teamName, existingNames);
|
||||||
const members = (data.members ?? [])
|
const members = (data.members ?? [])
|
||||||
.filter((m) => !m.removedAt && m.agentType !== 'team-lead')
|
.filter((m) => !m.removedAt && !isLeadAgentType(m.agentType))
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
let role = m.role;
|
let role = m.role;
|
||||||
if (!role && m.agentType && m.agentType !== 'general-purpose') {
|
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 [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(
|
const handleLaunchTeam = useCallback(
|
||||||
async (teamName: string, projectPath: string | undefined, e: React.MouseEvent) => {
|
async (teamName: string, projectPath: string | undefined, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!projectPath) return;
|
if (!projectPath) return;
|
||||||
setLaunchingTeamName(teamName);
|
|
||||||
try {
|
try {
|
||||||
await launchTeam({ teamName, cwd: projectPath });
|
const data = await api.teams.getData(teamName);
|
||||||
openTeamTab(teamName, projectPath);
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to launch team:', err);
|
console.error('Failed to launch team:', err);
|
||||||
} finally {
|
} 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 => (
|
const renderHeader = (): React.JSX.Element => (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -979,6 +1027,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
{createDialogElement}
|
{createDialogElement}
|
||||||
|
{launchDialogElement}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,14 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import {
|
import { TiptapEditor } from '@renderer/components/ui/tiptap';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@renderer/components/ui/select';
|
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,7 +29,7 @@ import {
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
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 { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -99,6 +92,7 @@ export const CreateTaskDialog = ({
|
||||||
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
||||||
const [blockedBySearch, setBlockedBySearch] = useState('');
|
const [blockedBySearch, setBlockedBySearch] = useState('');
|
||||||
const [relatedSearch, setRelatedSearch] = useState('');
|
const [relatedSearch, setRelatedSearch] = useState('');
|
||||||
|
const [showOptionalFields, setShowOptionalFields] = useState(false);
|
||||||
const prevOpenRef = useRef(false);
|
const prevOpenRef = useRef(false);
|
||||||
|
|
||||||
// Reset form when dialog opens (avoid setState during render)
|
// Reset form when dialog opens (avoid setState during render)
|
||||||
|
|
@ -124,6 +118,7 @@ export const CreateTaskDialog = ({
|
||||||
promptDraft.clearDraft();
|
promptDraft.clearDraft();
|
||||||
setBlockedBySearch('');
|
setBlockedBySearch('');
|
||||||
setRelatedSearch('');
|
setRelatedSearch('');
|
||||||
|
setShowOptionalFields(false);
|
||||||
}
|
}
|
||||||
prevOpenRef.current = open;
|
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 => {
|
const handleSubmit = (): void => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim());
|
const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim());
|
||||||
|
|
@ -214,44 +201,19 @@ export const CreateTaskDialog = ({
|
||||||
<Label className={requiresOwner ? undefined : 'label-optional'}>
|
<Label className={requiresOwner ? undefined : 'label-optional'}>
|
||||||
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
|
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<MemberSelect
|
||||||
value={owner || '__unassigned__'}
|
members={members}
|
||||||
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
|
value={owner || null}
|
||||||
>
|
onChange={(v) => setOwner(v ?? '')}
|
||||||
<SelectTrigger>
|
placeholder={requiresOwner ? 'Select a member' : 'Select member...'}
|
||||||
<SelectValue placeholder={requiresOwner ? 'Select a member' : 'Unassigned'} />
|
allowUnassigned={!requiresOwner}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
<DialogContent className="sm:max-w-[580px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Task</DialogTitle>
|
<DialogTitle>Create Task</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|
@ -294,51 +256,199 @@ export const CreateTaskDialog = ({
|
||||||
|
|
||||||
{assigneeField}
|
{assigneeField}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
{/* Toggle button for optional fields */}
|
||||||
<Label htmlFor="task-description" className="label-optional">
|
<button
|
||||||
Description (optional)
|
type="button"
|
||||||
</Label>
|
className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||||
<MentionableTextarea
|
onClick={() => setShowOptionalFields((prev) => !prev)}
|
||||||
id="task-description"
|
>
|
||||||
placeholder="Task details..."
|
{showOptionalFields ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
value={descriptionDraft.value}
|
<span>{showOptionalFields ? 'Hide optional fields' : 'Show optional fields'}</span>
|
||||||
onValueChange={descriptionDraft.setValue}
|
</button>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
{/* Collapsible optional fields */}
|
||||||
<Label htmlFor="task-prompt" className="label-optional">
|
<div
|
||||||
Prompt for assignee (optional)
|
className="grid overflow-hidden transition-all duration-200 ease-in-out"
|
||||||
</Label>
|
style={{ gridTemplateRows: showOptionalFields ? '1fr' : '0fr' }}
|
||||||
<MentionableTextarea
|
>
|
||||||
id="task-prompt"
|
<div className="min-h-0 overflow-hidden">
|
||||||
placeholder="Custom instructions for the team member..."
|
<div className="grid gap-4">
|
||||||
value={promptDraft.value}
|
<div className="grid gap-2">
|
||||||
onValueChange={promptDraft.setValue}
|
<Label className="label-optional">Description (optional)</Label>
|
||||||
suggestions={mentionSuggestions}
|
<TiptapEditor
|
||||||
taskSuggestions={taskSuggestions}
|
content={descriptionDraft.value}
|
||||||
projectPath={projectPath}
|
onChange={descriptionDraft.setValue}
|
||||||
minRows={3}
|
placeholder="Task details (supports markdown)"
|
||||||
maxRows={12}
|
minHeight="100px"
|
||||||
footerRight={
|
maxHeight="200px"
|
||||||
promptDraft.isSaved ? (
|
toolbar
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
/>
|
||||||
) : null
|
</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>
|
</div>
|
||||||
|
|
||||||
{owner ? (
|
{owner ? (
|
||||||
|
|
@ -364,147 +474,6 @@ export const CreateTaskDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
stripEncodedTaskReferenceMetadata,
|
stripEncodedTaskReferenceMetadata,
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberBadge } from '../MemberBadge';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
@ -128,7 +129,7 @@ export const SendMessageDialog = ({
|
||||||
} = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` });
|
} = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` });
|
||||||
|
|
||||||
const selectedMember = members.find((m) => m.name === member);
|
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 hasTeammates = members.length > 1;
|
||||||
const canDelegate = hasTeammates && isLeadRecipient;
|
const canDelegate = hasTeammates && isLeadRecipient;
|
||||||
const shouldAutoDelegate = canDelegate;
|
const shouldAutoDelegate = canDelegate;
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ interface TaskCommentsSectionProps {
|
||||||
* Ref callback factory from useViewportCommentRead.
|
* Ref callback factory from useViewportCommentRead.
|
||||||
* When provided, each comment element is registered for viewport-based read tracking.
|
* 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 = ({
|
export const TaskCommentsSection = ({
|
||||||
|
|
@ -216,9 +216,7 @@ export const TaskCommentsSection = ({
|
||||||
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
||||||
<div
|
<div
|
||||||
ref={
|
ref={
|
||||||
registerCommentForViewport
|
registerCommentForViewport ? registerCommentForViewport(comment.id) : undefined
|
||||||
? registerCommentForViewport(new Date(comment.createdAt).getTime())
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
className={[
|
className={[
|
||||||
'group min-w-0 overflow-hidden px-4 py-2.5',
|
'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 { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
import { TiptapEditor } from '@renderer/components/ui/tiptap';
|
import { TiptapEditor } from '@renderer/components/ui/tiptap';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
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 { useStore } from '@renderer/store';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useViewportCommentRead } from '@renderer/hooks/useViewportCommentRead';
|
import { useViewportCommentRead } from '@renderer/hooks/useViewportCommentRead';
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
|
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
|
||||||
import {
|
import {
|
||||||
|
|
@ -252,12 +253,14 @@ export const TaskDetailDialog = ({
|
||||||
unreadSnapshotRef.current = new Set();
|
unreadSnapshotRef.current = new Set();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lastRead = getLastReadTimestamp(teamName, currentTask.id);
|
const readIds = getReadCommentIds(teamName, currentTask.id);
|
||||||
|
const cutoff = getLegacyCutoff(teamName, currentTask.id);
|
||||||
const unread = new Set<string>();
|
const unread = new Set<string>();
|
||||||
for (const c of comments) {
|
for (const c of comments) {
|
||||||
if (new Date(c.createdAt).getTime() > lastRead) {
|
if (readIds.has(c.id)) continue;
|
||||||
unread.add(c.id);
|
const ts = new Date(c.createdAt).getTime();
|
||||||
}
|
if (cutoff > 0 && ts <= cutoff) continue;
|
||||||
|
unread.add(c.id);
|
||||||
}
|
}
|
||||||
unreadSnapshotRef.current = unread;
|
unreadSnapshotRef.current = unread;
|
||||||
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -507,8 +510,7 @@ export const TaskDetailDialog = ({
|
||||||
.map((t) => t.id);
|
.map((t) => t.id);
|
||||||
const isTodo = status === 'pending' && !kanbanColumn;
|
const isTodo = status === 'pending' && !kanbanColumn;
|
||||||
const canReassign = isTodo && onOwnerChange;
|
const canReassign = isTodo && onOwnerChange;
|
||||||
const leadName =
|
const leadName = members.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||||
members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead';
|
|
||||||
const isLeadOwnedTask =
|
const isLeadOwnedTask =
|
||||||
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
|
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
|
||||||
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
|
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ interface MemberCardProps {
|
||||||
spawnStatus?: MemberSpawnStatus;
|
spawnStatus?: MemberSpawnStatus;
|
||||||
spawnError?: string;
|
spawnError?: string;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
|
onOpenReviewTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSendMessage?: () => void;
|
onSendMessage?: () => void;
|
||||||
onAssignTask?: () => void;
|
onAssignTask?: () => void;
|
||||||
|
|
@ -56,6 +57,7 @@ export const MemberCard = ({
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnError,
|
spawnError,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
|
onOpenReviewTask,
|
||||||
onClick,
|
onClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -88,7 +90,6 @@ export const MemberCard = ({
|
||||||
const totalTasks = pending + inProgress + completed;
|
const totalTasks = pending + inProgress + completed;
|
||||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : null;
|
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
: reviewTask
|
: reviewTask
|
||||||
|
|
@ -131,21 +132,31 @@ export const MemberCard = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
<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 ? (
|
{member.gitBranch ? (
|
||||||
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<GitBranch size={10} />
|
<GitBranch size={10} />
|
||||||
{member.gitBranch}
|
{member.gitBranch}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{activityTask && activityLabel ? (
|
{currentTask ? (
|
||||||
<CurrentTaskIndicator
|
<CurrentTaskIndicator
|
||||||
task={activityTask}
|
task={currentTask}
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
activityLabel={activityLabel}
|
activityLabel="working on"
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{reviewTask ? (
|
||||||
|
<CurrentTaskIndicator
|
||||||
|
task={reviewTask}
|
||||||
|
borderColor={colors.border}
|
||||||
|
activityLabel="reviewing"
|
||||||
|
onOpenTask={onOpenReviewTask}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{!activityTask && isAwaitingReply ? (
|
{!activityTask && isAwaitingReply ? (
|
||||||
<>
|
<>
|
||||||
<Loader2
|
<Loader2
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||||
|
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||||
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||||
|
|
@ -98,7 +99,7 @@ export const MemberDetailDialog = ({
|
||||||
member={member}
|
member={member}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
|
||||||
onUpdateRole={
|
onUpdateRole={
|
||||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +187,7 @@ export const MemberDetailDialog = ({
|
||||||
<ListPlus size={14} />
|
<ListPlus size={14} />
|
||||||
Assign Task
|
Assign Task
|
||||||
</Button>
|
</Button>
|
||||||
{onRemoveMember && member.agentType !== 'team-lead' && (
|
{onRemoveMember && !isLeadAgentType(member.agentType) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
getMemberDotClass,
|
getMemberDotClass,
|
||||||
getPresenceLabel,
|
getPresenceLabel,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberRoleEditor } from './MemberRoleEditor';
|
import { MemberRoleEditor } from './MemberRoleEditor';
|
||||||
|
|
@ -47,7 +48,10 @@ export const MemberDetailHeader = ({
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
|
|
||||||
const canEditRole =
|
const canEditRole =
|
||||||
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
!isLeadAgentType(member.agentType) &&
|
||||||
|
!member.removedAt &&
|
||||||
|
!isTeamProvisioning &&
|
||||||
|
!!onUpdateRole;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
getMemberDotClass,
|
getMemberDotClass,
|
||||||
getPresenceLabel,
|
getPresenceLabel,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
|
@ -62,28 +63,26 @@ export const MemberHoverCard = ({
|
||||||
member,
|
member,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
false,
|
false,
|
||||||
member.agentType === 'team-lead' ? leadActivity : undefined
|
isLeadAgentType(member.agentType) ? leadActivity : undefined
|
||||||
);
|
);
|
||||||
const dotClass = getMemberDotClass(
|
const dotClass = getMemberDotClass(
|
||||||
member,
|
member,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
false,
|
false,
|
||||||
member.agentType === 'team-lead' ? leadActivity : undefined
|
isLeadAgentType(member.agentType) ? leadActivity : undefined
|
||||||
);
|
);
|
||||||
const currentTask: TeamTaskWithKanban | null =
|
const currentTask: TeamTaskWithKanban | null =
|
||||||
member.currentTaskId && tasks
|
member.currentTaskId && tasks
|
||||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const reviewTask: TeamTaskWithKanban | null =
|
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||||
!currentTask && tasks
|
? (tasks.find(
|
||||||
? (tasks.find(
|
(task) =>
|
||||||
(task) =>
|
task.reviewer === member.name &&
|
||||||
task.reviewer === member.name &&
|
task.id !== member.currentTaskId &&
|
||||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const activityTask = currentTask ?? reviewTask;
|
|
||||||
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : 'working on';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard openDelay={300} closeDelay={200}>
|
<HoverCard openDelay={300} closeDelay={200}>
|
||||||
|
|
@ -131,14 +130,27 @@ export const MemberHoverCard = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current task */}
|
{/* 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">
|
<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
|
<CurrentTaskIndicator
|
||||||
task={activityTask}
|
task={currentTask}
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
maxSubjectLength={28}
|
maxSubjectLength={28}
|
||||||
activityLabel={activityLabel}
|
activityLabel="working on"
|
||||||
onOpenTask={onOpenTask ? () => onOpenTask(activityTask) : undefined}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
|
|
@ -69,8 +70,8 @@ export const MemberList = ({
|
||||||
const activeMembers = members
|
const activeMembers = members
|
||||||
.filter((m) => !m.removedAt)
|
.filter((m) => !m.removedAt)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.agentType === 'team-lead') return -1;
|
if (isLeadAgentType(a.agentType)) return -1;
|
||||||
if (b.agentType === 'team-lead') return 1;
|
if (isLeadAgentType(b.agentType)) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
const removedMembers = members.filter((m) => m.removedAt);
|
const removedMembers = members.filter((m) => m.removedAt);
|
||||||
|
|
@ -87,14 +88,14 @@ export const MemberList = ({
|
||||||
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
||||||
const currentTask =
|
const currentTask =
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
const reviewTask =
|
const reviewTask = taskMap
|
||||||
!currentTask && taskMap
|
? (Array.from(taskMap.values()).find(
|
||||||
? (Array.from(taskMap.values()).find(
|
(task) =>
|
||||||
(task) =>
|
task.reviewer === member.name &&
|
||||||
task.reviewer === member.name &&
|
task.id !== member.currentTaskId &&
|
||||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
return (
|
return (
|
||||||
|
|
@ -105,18 +106,15 @@ export const MemberList = ({
|
||||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
|
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
|
||||||
currentTask={isRemoved ? null : currentTask}
|
currentTask={isRemoved ? null : currentTask}
|
||||||
reviewTask={isRemoved ? null : reviewTask}
|
reviewTask={isRemoved ? null : reviewTask}
|
||||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||||
isRemoved={isRemoved}
|
isRemoved={isRemoved}
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||||
onOpenTask={
|
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||||
!isRemoved && (currentTask ?? reviewTask)
|
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
|
||||||
? () => onOpenTask?.((currentTask ?? reviewTask)!)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => onMemberClick?.(member)}
|
onClick={() => onMemberClick?.(member)}
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
onSendMessage={() => onSendMessage?.(member)}
|
||||||
onAssignTask={() => onAssignTask?.(member)}
|
onAssignTask={() => onAssignTask?.(member)}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
stripEncodedTaskReferenceMetadata,
|
stripEncodedTaskReferenceMetadata,
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
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 { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -86,7 +87,7 @@ export const MessageComposer = ({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [externalTextareaRef]);
|
}, [externalTextareaRef]);
|
||||||
const [recipient, setRecipient] = useState<string>(() => {
|
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 ?? '';
|
return lead?.name ?? members[0]?.name ?? '';
|
||||||
});
|
});
|
||||||
const [recipientOpen, setRecipientOpen] = useState(false);
|
const [recipientOpen, setRecipientOpen] = useState(false);
|
||||||
|
|
@ -166,7 +167,7 @@ export const MessageComposer = ({
|
||||||
if (recipient && members.some((m) => m.name === recipient)) {
|
if (recipient && members.some((m) => m.name === recipient)) {
|
||||||
return;
|
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 ?? '';
|
const next = lead?.name ?? members[0]?.name ?? '';
|
||||||
if (next && next !== recipient) {
|
if (next && next !== recipient) {
|
||||||
queueMicrotask(() => setRecipient(next));
|
queueMicrotask(() => setRecipient(next));
|
||||||
|
|
@ -203,7 +204,7 @@ export const MessageComposer = ({
|
||||||
|
|
||||||
const selectedMember = members.find((m) => m.name === recipient);
|
const selectedMember = members.find((m) => m.name === recipient);
|
||||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
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 hasTeammates = members.length > 1;
|
||||||
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
|
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
|
||||||
const shouldAutoDelegate = isLeadRecipient && canDelegate;
|
const shouldAutoDelegate = isLeadRecipient && canDelegate;
|
||||||
|
|
@ -681,8 +682,8 @@ export const MessageComposer = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
|
const aIsLead = isLeadMember(a) ? 1 : 0;
|
||||||
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
|
const bIsLead = isLeadMember(b) ? 1 : 0;
|
||||||
return bIsLead - aIsLead;
|
return bIsLead - aIsLead;
|
||||||
});
|
});
|
||||||
return sorted.map((m) => {
|
return sorted.map((m) => {
|
||||||
|
|
@ -787,8 +788,8 @@ export const MessageComposer = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
|
const aIsLead = isLeadMember(a) ? 1 : 0;
|
||||||
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
|
const bIsLead = isLeadMember(b) ? 1 : 0;
|
||||||
return bIsLead - aIsLead;
|
return bIsLead - aIsLead;
|
||||||
});
|
});
|
||||||
return sorted.map((m) => {
|
return sorted.map((m) => {
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,4 @@ export const CUSTOM_ROLE = '__custom__';
|
||||||
export const NO_ROLE = '__none__';
|
export const NO_ROLE = '__none__';
|
||||||
|
|
||||||
/** Roles that cannot be assigned manually (reserved for system use). */
|
/** 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 { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markCommentsRead } from '@renderer/services/commentReadStorage';
|
||||||
|
|
||||||
import { useViewportObserver } from './useViewportObserver';
|
import { useViewportObserver } from './useViewportObserver';
|
||||||
|
|
||||||
|
|
@ -20,31 +20,31 @@ interface UseViewportCommentReadOptions {
|
||||||
/**
|
/**
|
||||||
* Marks task comments as read based on viewport visibility.
|
* Marks task comments as read based on viewport visibility.
|
||||||
*
|
*
|
||||||
* Instead of marking all comments read on mount, this hook uses
|
* Uses IntersectionObserver (via useViewportObserver) to detect which
|
||||||
* IntersectionObserver (via useViewportObserver) to detect which
|
* comment elements are visible in the scroll container and records
|
||||||
* comment elements are visible in the scroll container and updates
|
* their individual IDs as read via per-comment ID tracking.
|
||||||
* the per-task read timestamp to the newest visible comment.
|
|
||||||
*
|
*
|
||||||
* Each comment element should be registered via the returned
|
* 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
|
* Only comments that have actually been scrolled into view are marked
|
||||||
* in commentReadStorage — no storage schema changes needed.
|
* 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({
|
export function useViewportCommentRead({
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
}: UseViewportCommentReadOptions): {
|
}: UseViewportCommentReadOptions): {
|
||||||
/** Ref callback factory. Call with the comment's createdAt timestamp (ms). */
|
/** Ref callback factory. Call with the comment's unique ID. */
|
||||||
registerComment: (timestampMs: number) => (el: HTMLElement | null) => void;
|
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).
|
* as a safety fallback (e.g. if IO did not fire for portal reasons).
|
||||||
*/
|
*/
|
||||||
flush: () => void;
|
flush: () => void;
|
||||||
} {
|
} {
|
||||||
const highestSeenRef = useRef(0);
|
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||||
const teamNameRef = useRef(teamName);
|
const teamNameRef = useRef(teamName);
|
||||||
const taskIdRef = useRef(taskId);
|
const taskIdRef = useRef(taskId);
|
||||||
teamNameRef.current = teamName;
|
teamNameRef.current = teamName;
|
||||||
|
|
@ -52,23 +52,31 @@ export function useViewportCommentRead({
|
||||||
|
|
||||||
// Reset tracked state when team/task changes
|
// Reset tracked state when team/task changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
highestSeenRef.current = 0;
|
seenIdsRef.current = new Set();
|
||||||
}, [teamName, taskId]);
|
}, [teamName, taskId]);
|
||||||
|
|
||||||
const handleVisibleChange = useCallback((visibleValues: string[]) => {
|
const persistSeen = useCallback(() => {
|
||||||
let maxTs = 0;
|
if (seenIdsRef.current.size > 0) {
|
||||||
for (const v of visibleValues) {
|
markCommentsRead(teamNameRef.current, taskIdRef.current, Array.from(seenIdsRef.current));
|
||||||
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 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({
|
const { registerElement } = useViewportObserver({
|
||||||
rootRef: scrollContainerRef,
|
rootRef: scrollContainerRef,
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
|
|
@ -76,15 +84,13 @@ export function useViewportCommentRead({
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerComment = useCallback(
|
const registerComment = useCallback(
|
||||||
(timestampMs: number) => registerElement(String(timestampMs)),
|
(commentId: string) => registerElement(commentId),
|
||||||
[registerElement]
|
[registerElement]
|
||||||
);
|
);
|
||||||
|
|
||||||
const flush = useCallback(() => {
|
const flush = useCallback(() => {
|
||||||
if (highestSeenRef.current > 0) {
|
persistSeen();
|
||||||
markAsRead(teamNameRef.current, taskIdRef.current, highestSeenRef.current);
|
}, [persistSeen]);
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { registerComment, flush };
|
return { registerComment, flush };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,34 @@
|
||||||
import { get, set } from 'idb-keyval';
|
import { get, set } from 'idb-keyval';
|
||||||
|
|
||||||
const IDB_KEY = 'comment-read-state';
|
const IDB_KEY = 'comment-read-state-v2';
|
||||||
const LS_KEY = 'comment-read-state';
|
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 SAVE_DEBOUNCE_MS = 300;
|
||||||
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
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 {
|
function lsLoad(): ReadState | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(LS_KEY);
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
? (parsed as ReadState)
|
return parsed as ReadState;
|
||||||
: null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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
|
// Synchronous init from localStorage — guarantees first render sees read state
|
||||||
const lsInitial = lsLoad();
|
let cache: ReadState = {};
|
||||||
let cache: ReadState = lsInitial ?? {};
|
const v2Data = lsLoad();
|
||||||
let loaded = lsInitial !== null && Object.keys(lsInitial).length > 0;
|
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 idbAvailable = true; // flips to false on first IndexedDB failure
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
@ -51,31 +111,109 @@ export function getSnapshot(): ReadState {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mutations ---
|
// --- 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 key = `${teamName}/${taskId}`;
|
||||||
const prev = cache[key] ?? 0;
|
const prev = cache[key];
|
||||||
if (latestTimestamp <= prev) return;
|
const prevSet = new Set(prev?.readIds ?? []);
|
||||||
cache = { ...cache, [key]: latestTimestamp };
|
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();
|
notify();
|
||||||
scheduleSave();
|
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(
|
export function getUnreadCount(
|
||||||
readState: ReadState,
|
readState: ReadState,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
comments: { createdAt: string }[]
|
comments: { id?: string; createdAt: string }[]
|
||||||
): number {
|
): number {
|
||||||
if (!comments || comments.length === 0) return 0;
|
if (!comments || comments.length === 0) return 0;
|
||||||
const key = `${teamName}/${taskId}`;
|
const key = `${teamName}/${taskId}`;
|
||||||
const lastRead = readState[key] ?? 0;
|
const entry = readState[key];
|
||||||
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
|
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 {
|
export function getLastReadTimestamp(teamName: string, taskId: string): number {
|
||||||
const key = `${teamName}/${taskId}`;
|
const key = `${teamName}/${taskId}`;
|
||||||
return cache[key] ?? 0;
|
return cache[key]?.lastUpdated ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Internal ---
|
// --- Internal ---
|
||||||
|
|
@ -98,17 +236,48 @@ function scheduleSave(): void {
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
|
|
||||||
// IDB may have fresher data — merge with max timestamp per key
|
|
||||||
if (hasIndexedDB() && idbAvailable) {
|
if (hasIndexedDB() && idbAvailable) {
|
||||||
try {
|
try {
|
||||||
|
// Try v2 format first
|
||||||
const stored = await get<ReadState>(IDB_KEY);
|
const stored = await get<ReadState>(IDB_KEY);
|
||||||
if (stored && typeof stored === 'object') {
|
if (stored && typeof stored === 'object') {
|
||||||
const merged = { ...cache };
|
const merged = { ...cache };
|
||||||
for (const [k, v] of Object.entries(stored)) {
|
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;
|
cache = merged;
|
||||||
notify();
|
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 {
|
} catch {
|
||||||
idbAvailable = false;
|
idbAvailable = false;
|
||||||
|
|
@ -134,31 +303,27 @@ async function save(): Promise<void> {
|
||||||
|
|
||||||
export async function cleanupStale(): Promise<void> {
|
export async function cleanupStale(): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const clean = (state: ReadState): { cleaned: ReadState; changed: boolean } => {
|
let changed = false;
|
||||||
const result: ReadState = {};
|
const result: ReadState = {};
|
||||||
let changed = false;
|
for (const [k, v] of Object.entries(cache)) {
|
||||||
for (const [k, v] of Object.entries(state)) {
|
if (now - v.lastUpdated < STALE_THRESHOLD_MS) {
|
||||||
if (now - v < STALE_THRESHOLD_MS) {
|
result[k] = v;
|
||||||
result[k] = v;
|
} else {
|
||||||
} else {
|
changed = true;
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { cleaned: result, changed };
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const { cleaned, changed } = clean(cache);
|
|
||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
|
|
||||||
// Update in-memory cache
|
// Update in-memory cache
|
||||||
cache = cleaned;
|
cache = result;
|
||||||
notify();
|
notify();
|
||||||
|
|
||||||
// Persist to both storages
|
// Persist to both storages
|
||||||
lsSave(cleaned);
|
lsSave(result);
|
||||||
if (idbAvailable && hasIndexedDB()) {
|
if (idbAvailable && hasIndexedDB()) {
|
||||||
try {
|
try {
|
||||||
await set(IDB_KEY, cleaned);
|
await set(IDB_KEY, result);
|
||||||
} catch {
|
} catch {
|
||||||
idbAvailable = false;
|
idbAvailable = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
|
||||||
selectProject: (id: string) => {
|
selectProject: (id: string) => {
|
||||||
set({
|
set({
|
||||||
selectedProjectId: id,
|
selectedProjectId: id,
|
||||||
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
|
|
||||||
...getSessionResetState(),
|
...getSessionResetState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,6 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
||||||
selectedWorktreeId: worktreeToSelect.id,
|
selectedWorktreeId: worktreeToSelect.id,
|
||||||
selectedProjectId: worktreeToSelect.id,
|
selectedProjectId: worktreeToSelect.id,
|
||||||
activeProjectId: worktreeToSelect.id,
|
activeProjectId: worktreeToSelect.id,
|
||||||
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
|
|
||||||
...getSessionResetState(),
|
...getSessionResetState(),
|
||||||
});
|
});
|
||||||
// Fetch sessions for this worktree
|
// Fetch sessions for this worktree
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ export function getWorktreeNavigationState(repoId: string, worktreeId: string):
|
||||||
selectedWorktreeId: worktreeId,
|
selectedWorktreeId: worktreeId,
|
||||||
selectedProjectId: worktreeId,
|
selectedProjectId: worktreeId,
|
||||||
activeProjectId: worktreeId,
|
activeProjectId: worktreeId,
|
||||||
sidebarCollapsed: false,
|
|
||||||
...getSessionResetState(),
|
...getSessionResetState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
MEMBER_COLOR_PALETTE,
|
MEMBER_COLOR_PALETTE,
|
||||||
normalizeMemberColorName,
|
normalizeMemberColorName,
|
||||||
} from '@shared/constants/memberColors';
|
} from '@shared/constants/memberColors';
|
||||||
|
import { isLeadAgentType } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
|
@ -42,7 +43,7 @@ export function getMemberDotClass(
|
||||||
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
|
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
|
||||||
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
|
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
|
||||||
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
|
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
|
||||||
if (leadActivity && member.agentType === 'team-lead') {
|
if (leadActivity && isLeadAgentType(member.agentType)) {
|
||||||
return leadActivity === 'active'
|
return leadActivity === 'active'
|
||||||
? `${STATUS_DOT_COLORS.active} animate-pulse`
|
? `${STATUS_DOT_COLORS.active} animate-pulse`
|
||||||
: STATUS_DOT_COLORS.active;
|
: STATUS_DOT_COLORS.active;
|
||||||
|
|
@ -62,7 +63,7 @@ export function getPresenceLabel(
|
||||||
if (member.status === 'terminated') return 'terminated';
|
if (member.status === 'terminated') return 'terminated';
|
||||||
if (isTeamProvisioning) return 'connecting';
|
if (isTeamProvisioning) return 'connecting';
|
||||||
if (isTeamAlive === false) return 'offline';
|
if (isTeamAlive === false) return 'offline';
|
||||||
if (leadActivity && member.agentType === 'team-lead') {
|
if (leadActivity && isLeadAgentType(member.agentType)) {
|
||||||
if (leadActivity === 'active') {
|
if (leadActivity === 'active') {
|
||||||
return leadContextPercent != null && leadContextPercent > 0
|
return leadContextPercent != null && leadContextPercent > 0
|
||||||
? `processing (${Math.round(leadContextPercent)}%)`
|
? `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